├── app ├── .gitignore ├── src │ ├── main │ │ ├── res │ │ │ ├── font │ │ │ │ ├── work_sans_italic.ttf │ │ │ │ └── work_sans_normal.ttf │ │ │ ├── mipmap-hdpi │ │ │ │ ├── ic_launcher.webp │ │ │ │ └── ic_launcher_round.webp │ │ │ ├── mipmap-mdpi │ │ │ │ ├── ic_launcher.webp │ │ │ │ └── ic_launcher_round.webp │ │ │ ├── mipmap-xhdpi │ │ │ │ ├── ic_launcher.webp │ │ │ │ └── ic_launcher_round.webp │ │ │ ├── mipmap-xxhdpi │ │ │ │ ├── ic_launcher.webp │ │ │ │ └── ic_launcher_round.webp │ │ │ ├── mipmap-xxxhdpi │ │ │ │ ├── ic_launcher.webp │ │ │ │ └── ic_launcher_round.webp │ │ │ ├── values │ │ │ │ ├── ic_launcher_background.xml │ │ │ │ ├── colors.xml │ │ │ │ ├── attrs.xml │ │ │ │ ├── themes.xml │ │ │ │ └── dimens.xml │ │ │ ├── drawable │ │ │ │ ├── shape_border_grey.xml │ │ │ │ ├── shape_bottom_sheet_background.xml │ │ │ │ ├── ic_red_circle_24.xml │ │ │ │ ├── ic_resistance.xml │ │ │ │ ├── outline_content_copy_24.xml │ │ │ │ ├── ic_required.xml │ │ │ │ ├── layer_list_progress_bar.xml │ │ │ │ ├── ic_fb.xml │ │ │ │ ├── ic_bell.xml │ │ │ │ ├── ic_yt.xml │ │ │ │ ├── ic_nthlink.xml │ │ │ │ ├── ic_book.xml │ │ │ │ ├── ic_to_expand.xml │ │ │ │ ├── ic_nthlink_logo_blue.xml │ │ │ │ ├── ic_nthlink_logo_white.xml │ │ │ │ ├── ic_launcher_foreground.xml │ │ │ │ ├── ic_tg.xml │ │ │ │ └── ic_ig.xml │ │ │ ├── xml │ │ │ │ └── network_security_config.xml │ │ │ ├── mipmap-anydpi-v26 │ │ │ │ ├── ic_launcher.xml │ │ │ │ └── ic_launcher_round.xml │ │ │ ├── menu │ │ │ │ ├── fragment_connection.xml │ │ │ │ ├── fragment_web.xml │ │ │ │ └── navigation_drawer.xml │ │ │ ├── layout │ │ │ │ ├── activity_launch.xml │ │ │ │ ├── action_view_update.xml │ │ │ │ ├── layout_header_drawer.xml │ │ │ │ ├── view_holder_notification.xml │ │ │ │ ├── fragment_launch.xml │ │ │ │ ├── view_holder_news_title.xml │ │ │ │ ├── layout_toolbar.xml │ │ │ │ ├── fragment_web.xml │ │ │ │ ├── bottom_sheet_switch.xml │ │ │ │ ├── fragment_about.xml │ │ │ │ ├── fragment_diagnostic.xml │ │ │ │ ├── activity_main.xml │ │ │ │ ├── fragment_connection.xml │ │ │ │ ├── fragment_apk_update.xml │ │ │ │ ├── fragment_feedback.xml │ │ │ │ └── fragment_privacy.xml │ │ │ ├── values-night │ │ │ │ └── themes.xml │ │ │ ├── navigation │ │ │ │ ├── launch_graph.xml │ │ │ │ └── main_graph.xml │ │ │ ├── values-zh-rCN │ │ │ │ └── strings.xml │ │ │ ├── values-zh-rTW │ │ │ │ └── strings.xml │ │ │ ├── values-ko │ │ │ │ └── strings.xml │ │ │ ├── values-ja-rJP │ │ │ │ └── strings.xml │ │ │ ├── values-am-rET │ │ │ │ └── strings.xml │ │ │ ├── values-ar │ │ │ │ └── strings.xml │ │ │ ├── values-th-rTH │ │ │ │ └── strings.xml │ │ │ └── values-fa │ │ │ │ └── strings.xml │ │ ├── java │ │ │ └── com │ │ │ │ └── nthlink │ │ │ │ └── android │ │ │ │ └── client │ │ │ │ ├── updates │ │ │ │ ├── DownloadState.kt │ │ │ │ ├── InAppUpdate.kt │ │ │ │ ├── ApkDownloadReceiver.kt │ │ │ │ ├── InAppUpdateApk.kt │ │ │ │ └── InAppUpdatePlay.kt │ │ │ │ ├── storage │ │ │ │ ├── sql │ │ │ │ │ ├── ClickedNews.kt │ │ │ │ │ ├── AppDatabase.kt │ │ │ │ │ ├── ClickedNewsDao.kt │ │ │ │ │ └── NewsAnalyzer.kt │ │ │ │ └── datastore │ │ │ │ │ ├── DataStoreX.kt │ │ │ │ │ ├── ApkDownloadDataStore.kt │ │ │ │ │ └── CommonDataStore.kt │ │ │ │ ├── utils │ │ │ │ ├── JsonParser.kt │ │ │ │ ├── MarginItemDecoration.kt │ │ │ │ ├── ActivityX.kt │ │ │ │ ├── FragmentX.kt │ │ │ │ └── Utils.kt │ │ │ │ ├── ui │ │ │ │ ├── about │ │ │ │ │ └── AboutFragment.kt │ │ │ │ ├── LaunchActivity.kt │ │ │ │ ├── update │ │ │ │ │ ├── ApkUpdateViewModel.kt │ │ │ │ │ └── ApkUpdateFragment.kt │ │ │ │ ├── common │ │ │ │ │ └── BindingFragment.kt │ │ │ │ ├── web │ │ │ │ │ ├── WebUtils.kt │ │ │ │ │ └── WebFragment.kt │ │ │ │ ├── connection │ │ │ │ │ ├── NewsAdapter.kt │ │ │ │ │ ├── SwitchBottomSheet.kt │ │ │ │ │ └── NewsItem.kt │ │ │ │ ├── privacy │ │ │ │ │ └── PrivacyFragment.kt │ │ │ │ ├── launch │ │ │ │ │ └── LaunchFragment.kt │ │ │ │ ├── follow │ │ │ │ │ └── FollowUsFragment.kt │ │ │ │ └── diagnostic │ │ │ │ │ └── DiagnosticFragment.kt │ │ │ │ ├── di │ │ │ │ └── KoinModules.kt │ │ │ │ └── App.kt │ │ └── AndroidManifest.xml │ ├── test │ │ └── java │ │ │ └── com │ │ │ └── nthlink │ │ │ └── android │ │ │ └── client │ │ │ └── ExampleUnitTest.kt │ └── androidTest │ │ └── java │ │ └── com │ │ └── nthlink │ │ └── android │ │ └── client │ │ └── ExampleInstrumentedTest.kt ├── proguard-rules.pro └── build.gradle ├── core ├── .gitignore ├── consumer-rules.pro ├── src │ ├── main │ │ ├── java │ │ │ └── com │ │ │ │ └── nthlink │ │ │ │ └── android │ │ │ │ └── core │ │ │ │ ├── utils │ │ │ │ ├── ContextX.kt │ │ │ │ ├── JsonParser.kt │ │ │ │ └── Utils.kt │ │ │ │ ├── model │ │ │ │ ├── GetConfigResult.kt │ │ │ │ ├── DiagnosisReport.kt │ │ │ │ └── Config.kt │ │ │ │ ├── RootVpnClient.kt │ │ │ │ ├── storage │ │ │ │ ├── Storage.kt │ │ │ │ └── DataStore.kt │ │ │ │ ├── Root.kt │ │ │ │ ├── Core.kt │ │ │ │ └── RootVpn.kt │ │ ├── res │ │ │ ├── xml │ │ │ │ └── network_security_config.xml │ │ │ └── drawable │ │ │ │ └── ic_baseline_android_24.xml │ │ └── AndroidManifest.xml │ ├── test │ │ └── java │ │ │ └── com │ │ │ └── nthlink │ │ │ └── android │ │ │ └── core │ │ │ └── ExampleUnitTest.kt │ └── androidTest │ │ └── java │ │ └── com │ │ └── nthlink │ │ └── android │ │ └── core │ │ └── ExampleInstrumentedTest.kt ├── proguard-rules.pro └── build.gradle ├── outline ├── .gitignore ├── build.gradle └── nthlink-outline.aar ├── gradle └── wrapper │ └── gradle-wrapper.properties ├── .gitignore ├── settings.gradle ├── gradle.properties ├── LICENSE ├── README.md └── gradlew.bat /app/.gitignore: -------------------------------------------------------------------------------- 1 | /build -------------------------------------------------------------------------------- /core/.gitignore: -------------------------------------------------------------------------------- 1 | /build -------------------------------------------------------------------------------- /core/consumer-rules.pro: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /outline/.gitignore: -------------------------------------------------------------------------------- 1 | build -------------------------------------------------------------------------------- /outline/build.gradle: -------------------------------------------------------------------------------- 1 | configurations.maybeCreate("default") 2 | artifacts.add("default", file('nthlink-outline.aar')) -------------------------------------------------------------------------------- /outline/nthlink-outline.aar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nthlink/nthlink-os-android/HEAD/outline/nthlink-outline.aar -------------------------------------------------------------------------------- /app/src/main/res/font/work_sans_italic.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nthlink/nthlink-os-android/HEAD/app/src/main/res/font/work_sans_italic.ttf -------------------------------------------------------------------------------- /app/src/main/res/font/work_sans_normal.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nthlink/nthlink-os-android/HEAD/app/src/main/res/font/work_sans_normal.ttf -------------------------------------------------------------------------------- /app/src/main/res/mipmap-hdpi/ic_launcher.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nthlink/nthlink-os-android/HEAD/app/src/main/res/mipmap-hdpi/ic_launcher.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-mdpi/ic_launcher.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nthlink/nthlink-os-android/HEAD/app/src/main/res/mipmap-mdpi/ic_launcher.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xhdpi/ic_launcher.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nthlink/nthlink-os-android/HEAD/app/src/main/res/mipmap-xhdpi/ic_launcher.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxhdpi/ic_launcher.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nthlink/nthlink-os-android/HEAD/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nthlink/nthlink-os-android/HEAD/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-hdpi/ic_launcher_round.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nthlink/nthlink-os-android/HEAD/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-mdpi/ic_launcher_round.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nthlink/nthlink-os-android/HEAD/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nthlink/nthlink-os-android/HEAD/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nthlink/nthlink-os-android/HEAD/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nthlink/nthlink-os-android/HEAD/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp -------------------------------------------------------------------------------- /app/src/main/res/values/ic_launcher_background.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | #FFFFFF 4 | -------------------------------------------------------------------------------- /core/src/main/java/com/nthlink/android/core/utils/ContextX.kt: -------------------------------------------------------------------------------- 1 | package com.nthlink.android.core.utils 2 | 3 | import android.content.Context 4 | import android.net.ConnectivityManager 5 | 6 | fun Context.getConnectivityManager() = getSystemService(ConnectivityManager::class.java) -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | #Wed Feb 07 10:15:05 CST 2024 2 | distributionBase=GRADLE_USER_HOME 3 | distributionPath=wrapper/dists 4 | distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip 5 | zipStoreBase=GRADLE_USER_HOME 6 | zipStorePath=wrapper/dists 7 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/shape_border_grey.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /app/src/main/res/xml/network_security_config.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /core/src/main/res/xml/network_security_config.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/shape_bottom_sheet_background.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /app/src/main/res/menu/fragment_connection.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 9 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Compiled class file 2 | *.class 3 | 4 | # Log file 5 | *.log 6 | 7 | # BlueJ files 8 | *.ctxt 9 | 10 | # Mobile Tools for Java (J2ME) 11 | .mtj.tmp/ 12 | 13 | # Package Files # 14 | *.jar 15 | *.war 16 | *.nar 17 | *.ear 18 | *.zip 19 | *.tar.gz 20 | *.rar 21 | 22 | # virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml 23 | hs_err_pid* 24 | replay_pid* 25 | 26 | # macOS 27 | .DS_Store 28 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_red_circle_24.xml: -------------------------------------------------------------------------------- 1 | 7 | 8 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /app/src/main/java/com/nthlink/android/client/updates/DownloadState.kt: -------------------------------------------------------------------------------- 1 | package com.nthlink.android.client.updates 2 | 3 | /** 4 | * Sealed class representing download states 5 | */ 6 | sealed interface DownloadState { 7 | data object Idle : DownloadState 8 | data class Downloading(val progress: Int) : DownloadState 9 | data object Cancelled : DownloadState 10 | data object Completed : DownloadState 11 | data class Failed(val error: String) : DownloadState 12 | } -------------------------------------------------------------------------------- /app/src/test/java/com/nthlink/android/client/ExampleUnitTest.kt: -------------------------------------------------------------------------------- 1 | package com.nthlink.android.client 2 | 3 | import org.junit.Test 4 | 5 | import org.junit.Assert.* 6 | 7 | /** 8 | * Example local unit test, which will execute on the development machine (host). 9 | * 10 | * See [testing documentation](http://d.android.com/tools/testing). 11 | */ 12 | class ExampleUnitTest { 13 | @Test 14 | fun addition_isCorrect() { 15 | assertEquals(4, 2 + 2) 16 | } 17 | } -------------------------------------------------------------------------------- /core/src/test/java/com/nthlink/android/core/ExampleUnitTest.kt: -------------------------------------------------------------------------------- 1 | package com.nthlink.android.core 2 | 3 | import org.junit.Assert.assertEquals 4 | import org.junit.Test 5 | 6 | /** 7 | * Example local unit test, which will execute on the development machine (host). 8 | * 9 | * See [testing documentation](http://d.android.com/tools/testing). 10 | */ 11 | class ExampleUnitTest { 12 | @Test 13 | fun addition_isCorrect() { 14 | assertEquals(4, 2 + 2) 15 | } 16 | } -------------------------------------------------------------------------------- /app/src/main/java/com/nthlink/android/client/storage/sql/ClickedNews.kt: -------------------------------------------------------------------------------- 1 | package com.nthlink.android.client.storage.sql 2 | 3 | import androidx.room.ColumnInfo 4 | import androidx.room.Entity 5 | import androidx.room.PrimaryKey 6 | 7 | @Entity(tableName = "clicked_news") 8 | data class ClickedNews( 9 | @PrimaryKey(autoGenerate = true) val id: Long = 0, 10 | @ColumnInfo(name = "category") val category: String, 11 | @ColumnInfo(name = "timestamp") val timestamp: Long 12 | ) 13 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_resistance.xml: -------------------------------------------------------------------------------- 1 | 6 | 12 | 13 | -------------------------------------------------------------------------------- /settings.gradle: -------------------------------------------------------------------------------- 1 | pluginManagement { 2 | repositories { 3 | google() 4 | mavenCentral() 5 | gradlePluginPortal() 6 | } 7 | } 8 | dependencyResolutionManagement { 9 | repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) 10 | repositories { 11 | google() 12 | mavenCentral() 13 | maven { url "https://jitpack.io" } 14 | } 15 | } 16 | 17 | rootProject.name = "nthlink6-android" 18 | include ':app' 19 | include ':core' 20 | -------------------------------------------------------------------------------- /app/src/main/java/com/nthlink/android/client/utils/JsonParser.kt: -------------------------------------------------------------------------------- 1 | package com.nthlink.android.client.utils 2 | 3 | import kotlinx.serialization.json.Json 4 | import kotlinx.serialization.json.JsonElement 5 | 6 | internal object JsonParser { 7 | 8 | private val format = Json { 9 | ignoreUnknownKeys = true 10 | coerceInputValues = true // setting default value if it's null 11 | } 12 | 13 | fun toJsonElement(json: String): JsonElement = format.parseToJsonElement(json) 14 | } -------------------------------------------------------------------------------- /app/src/main/res/menu/fragment_web.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 7 | 10 | 13 | 14 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/outline_content_copy_24.xml: -------------------------------------------------------------------------------- 1 | 7 | 8 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /app/src/main/java/com/nthlink/android/client/ui/about/AboutFragment.kt: -------------------------------------------------------------------------------- 1 | package com.nthlink.android.client.ui.about 2 | 3 | import android.view.LayoutInflater 4 | import android.view.ViewGroup 5 | import com.nthlink.android.client.databinding.FragmentAboutBinding 6 | import com.nthlink.android.client.ui.common.BindingFragment 7 | 8 | class AboutFragment : BindingFragment() { 9 | override fun bindView(inflater: LayoutInflater, container: ViewGroup?): FragmentAboutBinding { 10 | return FragmentAboutBinding.inflate(inflater, container, false) 11 | } 12 | } -------------------------------------------------------------------------------- /app/src/main/res/layout/activity_launch.xml: -------------------------------------------------------------------------------- 1 | 2 | 12 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_required.xml: -------------------------------------------------------------------------------- 1 | 6 | 7 | 8 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/layer_list_progress_bar.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /app/src/main/res/layout/action_view_update.xml: -------------------------------------------------------------------------------- 1 | 2 | 8 | 9 | 15 | 16 | -------------------------------------------------------------------------------- /core/src/main/java/com/nthlink/android/core/model/GetConfigResult.kt: -------------------------------------------------------------------------------- 1 | package com.nthlink.android.core.model 2 | 3 | import kotlinx.serialization.SerialName 4 | import kotlinx.serialization.Serializable 5 | 6 | @Serializable 7 | data class RequestResult( 8 | @SerialName("timestamp") val timestamp: String, 9 | @SerialName("domainName") val domainName: String, 10 | @SerialName("message") val message: String, 11 | @SerialName("responseStatusCode") val responseStatusCode: Int 12 | ) 13 | 14 | @Serializable 15 | data class VerifyResult( 16 | @SerialName("timestamp") val timestamp: String, 17 | @SerialName("message") val message: String 18 | ) 19 | 20 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_fb.xml: -------------------------------------------------------------------------------- 1 | 6 | 9 | 10 | -------------------------------------------------------------------------------- /app/src/main/res/layout/layout_header_drawer.xml: -------------------------------------------------------------------------------- 1 | 2 | 7 | 8 | 14 | 15 | -------------------------------------------------------------------------------- /app/src/main/java/com/nthlink/android/client/storage/sql/AppDatabase.kt: -------------------------------------------------------------------------------- 1 | package com.nthlink.android.client.storage.sql 2 | 3 | import android.content.Context 4 | import androidx.room.Database 5 | import androidx.room.Room 6 | import androidx.room.RoomDatabase 7 | 8 | @Database(entities = [ClickedNews::class], version = 1) 9 | abstract class AppDatabase : RoomDatabase() { 10 | 11 | companion object { 12 | fun getInstance(applicationContext: Context): AppDatabase = Room.databaseBuilder( 13 | applicationContext, 14 | AppDatabase::class.java, 15 | "nthlink_db" 16 | ).build() 17 | } 18 | 19 | abstract fun clickedNewsDao(): ClickedNewsDao 20 | } -------------------------------------------------------------------------------- /core/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 11 | 12 | 15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /core/src/main/java/com/nthlink/android/core/utils/JsonParser.kt: -------------------------------------------------------------------------------- 1 | package com.nthlink.android.core.utils 2 | 3 | import com.nthlink.android.core.model.Config 4 | import com.nthlink.android.core.model.DiagnosisReport 5 | import kotlinx.serialization.json.Json 6 | 7 | internal object JsonParser { 8 | 9 | private val format = Json { 10 | ignoreUnknownKeys = true 11 | coerceInputValues = true // setting default value if it's null 12 | } 13 | 14 | fun toJson(config: Config): String = format.encodeToString(config) 15 | fun toJson(report: DiagnosisReport): String = format.encodeToString(report) 16 | 17 | fun toConfig(json: String): Config = format.decodeFromString(json) 18 | } -------------------------------------------------------------------------------- /app/src/main/java/com/nthlink/android/client/ui/LaunchActivity.kt: -------------------------------------------------------------------------------- 1 | package com.nthlink.android.client.ui 2 | 3 | import android.os.Bundle 4 | import androidx.appcompat.app.AppCompatActivity 5 | import com.nthlink.android.client.databinding.ActivityLaunchBinding 6 | 7 | class LaunchActivity : AppCompatActivity() { 8 | 9 | private lateinit var binding: ActivityLaunchBinding 10 | 11 | override fun onCreate(savedInstanceState: Bundle?) { 12 | super.onCreate(savedInstanceState) 13 | binding = ActivityLaunchBinding.inflate(layoutInflater) 14 | setContentView(binding.root) 15 | } 16 | 17 | fun moveToMainActivity() { 18 | MainActivity.start(this) 19 | finish() 20 | } 21 | } -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_bell.xml: -------------------------------------------------------------------------------- 1 | 6 | 9 | 10 | -------------------------------------------------------------------------------- /app/src/main/java/com/nthlink/android/client/di/KoinModules.kt: -------------------------------------------------------------------------------- 1 | package com.nthlink.android.client.di 2 | 3 | import com.nthlink.android.client.storage.datastore.ApkDownloadDataStore 4 | import com.nthlink.android.client.storage.datastore.CommonDataStore 5 | import com.nthlink.android.client.ui.update.ApkUpdateViewModel 6 | import com.nthlink.android.client.updates.ApkDownloadManager 7 | import org.koin.core.module.dsl.factoryOf 8 | import org.koin.core.module.dsl.singleOf 9 | import org.koin.core.module.dsl.viewModelOf 10 | import org.koin.dsl.module 11 | 12 | val appModule = module { 13 | // DataStore 14 | singleOf(::CommonDataStore) 15 | singleOf(::ApkDownloadDataStore) 16 | 17 | factoryOf(::ApkDownloadManager) 18 | 19 | viewModelOf(::ApkUpdateViewModel) 20 | } -------------------------------------------------------------------------------- /app/src/main/java/com/nthlink/android/client/utils/MarginItemDecoration.kt: -------------------------------------------------------------------------------- 1 | package com.nthlink.android.client.utils 2 | 3 | import android.graphics.Rect 4 | import android.view.View 5 | import androidx.recyclerview.widget.RecyclerView 6 | 7 | class MarginItemDecoration(private val spaceHeight: Int) : RecyclerView.ItemDecoration() { 8 | 9 | override fun getItemOffsets( 10 | outRect: Rect, 11 | view: View, 12 | parent: RecyclerView, 13 | state: RecyclerView.State 14 | ) { 15 | with(outRect) { 16 | if (parent.getChildAdapterPosition(view) == 0) { 17 | top = spaceHeight 18 | } 19 | //left = spaceHeight 20 | //right = spaceHeight 21 | bottom = spaceHeight 22 | } 23 | } 24 | } -------------------------------------------------------------------------------- /app/src/main/java/com/nthlink/android/client/storage/datastore/DataStoreX.kt: -------------------------------------------------------------------------------- 1 | package com.nthlink.android.client.storage.datastore 2 | 3 | import androidx.datastore.core.DataStore 4 | import androidx.datastore.preferences.core.Preferences 5 | import androidx.datastore.preferences.core.edit 6 | import kotlinx.coroutines.flow.first 7 | 8 | /** 9 | * Preferences DataStore Extension Functions 10 | */ 11 | 12 | internal suspend fun DataStore.save(key: Preferences.Key, value: T) { 13 | edit { prefs -> prefs[key] = value } 14 | } 15 | 16 | internal suspend fun DataStore.read(key: Preferences.Key): T? { 17 | return data.first()[key] 18 | } 19 | 20 | internal suspend fun DataStore.remove(key: Preferences.Key) { 21 | edit { prefs -> prefs.remove(key) } 22 | } -------------------------------------------------------------------------------- /core/src/androidTest/java/com/nthlink/android/core/ExampleInstrumentedTest.kt: -------------------------------------------------------------------------------- 1 | package com.nthlink.android.core 2 | 3 | import androidx.test.ext.junit.runners.AndroidJUnit4 4 | import androidx.test.platform.app.InstrumentationRegistry 5 | import org.junit.Assert.* 6 | import org.junit.Test 7 | import org.junit.runner.RunWith 8 | 9 | /** 10 | * Instrumented test, which will execute on an Android device. 11 | * 12 | * See [testing documentation](http://d.android.com/tools/testing). 13 | */ 14 | @RunWith(AndroidJUnit4::class) 15 | class ExampleInstrumentedTest { 16 | @Test 17 | fun useAppContext() { 18 | // Context of the app under test. 19 | val appContext = InstrumentationRegistry.getInstrumentation().targetContext 20 | assertEquals("com.nthlink.android.core.test", appContext.packageName) 21 | } 22 | } -------------------------------------------------------------------------------- /app/proguard-rules.pro: -------------------------------------------------------------------------------- 1 | # Add project specific ProGuard rules here. 2 | # You can control the set of applied configuration files using the 3 | # proguardFiles setting in build.gradle. 4 | # 5 | # For more details, see 6 | # http://developer.android.com/guide/developing/tools/proguard.html 7 | 8 | # If your project uses WebView with JS, uncomment the following 9 | # and specify the fully qualified class name to the JavaScript interface 10 | # class: 11 | #-keepclassmembers class fqcn.of.javascript.interface.for.webview { 12 | # public *; 13 | #} 14 | 15 | # Uncomment this to preserve the line number information for 16 | # debugging stack traces. 17 | #-keepattributes SourceFile,LineNumberTable 18 | 19 | # If you keep the line number information, uncomment this to 20 | # hide the original source file name. 21 | #-renamesourcefileattribute SourceFile -------------------------------------------------------------------------------- /app/src/androidTest/java/com/nthlink/android/client/ExampleInstrumentedTest.kt: -------------------------------------------------------------------------------- 1 | package com.nthlink.android.client 2 | 3 | import androidx.test.platform.app.InstrumentationRegistry 4 | import androidx.test.ext.junit.runners.AndroidJUnit4 5 | 6 | import org.junit.Test 7 | import org.junit.runner.RunWith 8 | 9 | import org.junit.Assert.* 10 | 11 | /** 12 | * Instrumented test, which will execute on an Android device. 13 | * 14 | * See [testing documentation](http://d.android.com/tools/testing). 15 | */ 16 | @RunWith(AndroidJUnit4::class) 17 | class ExampleInstrumentedTest { 18 | @Test 19 | fun useAppContext() { 20 | // Context of the app under test. 21 | val appContext = InstrumentationRegistry.getInstrumentation().targetContext 22 | assertEquals("com.nthlink.android.client", appContext.packageName) 23 | } 24 | } -------------------------------------------------------------------------------- /core/proguard-rules.pro: -------------------------------------------------------------------------------- 1 | # Add project specific ProGuard rules here. 2 | # You can control the set of applied configuration files using the 3 | # proguardFiles setting in build.gradle. 4 | # 5 | # For more details, see 6 | # http://developer.android.com/guide/developing/tools/proguard.html 7 | 8 | # If your project uses WebView with JS, uncomment the following 9 | # and specify the fully qualified class name to the JavaScript interface 10 | # class: 11 | #-keepclassmembers class fqcn.of.javascript.interface.for.webview { 12 | # public *; 13 | #} 14 | 15 | # Uncomment this to preserve the line number information for 16 | # debugging stack traces. 17 | #-keepattributes SourceFile,LineNumberTable 18 | 19 | # If you keep the line number information, uncomment this to 20 | # hide the original source file name. 21 | #-renamesourcefileattribute SourceFile -------------------------------------------------------------------------------- /core/src/main/java/com/nthlink/android/core/RootVpnClient.kt: -------------------------------------------------------------------------------- 1 | package com.nthlink.android.core 2 | 3 | import android.content.Context 4 | import com.nthlink.android.core.model.Config 5 | import kotlinx.coroutines.delay 6 | 7 | internal class RootVpnClient(context: Context) : RootVpn(context) { 8 | override suspend fun runVpn(servers: List) { 9 | // TODO Not yet implemented 10 | runVpn("") 11 | } 12 | 13 | override suspend fun runVpn(config: String) { 14 | // TODO Not yet implemented 15 | updateStatus(Root.Status.CONNECTING) 16 | delay(1000) 17 | updateStatus(Root.Status.CONNECTED) 18 | } 19 | 20 | override fun disconnect() { 21 | // TODO Not yet implemented 22 | updateStatus(Root.Status.DISCONNECTING) 23 | updateStatus(Root.Status.DISCONNECTED) 24 | } 25 | } -------------------------------------------------------------------------------- /app/src/main/java/com/nthlink/android/client/ui/update/ApkUpdateViewModel.kt: -------------------------------------------------------------------------------- 1 | package com.nthlink.android.client.ui.update 2 | 3 | import androidx.lifecycle.ViewModel 4 | import com.nthlink.android.client.updates.ApkDownloadManager 5 | import com.nthlink.android.client.updates.DownloadState 6 | import kotlinx.coroutines.flow.StateFlow 7 | 8 | class ApkUpdateViewModel(private val downloadManager: ApkDownloadManager) : ViewModel() { 9 | 10 | val downloadState: StateFlow = downloadManager.downloadState 11 | 12 | fun startDownload(version: String, downloadUrl: String) { 13 | downloadManager.startDownload(version, downloadUrl) 14 | } 15 | 16 | fun cancelDownload() { 17 | downloadManager.cancelDownload() 18 | } 19 | 20 | override fun onCleared() { 21 | super.onCleared() 22 | downloadManager.cleanup() 23 | } 24 | } -------------------------------------------------------------------------------- /app/src/main/res/values/colors.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | #FFBB86FC 4 | #FF3700B3 5 | #FF03DAC5 6 | #FF018786 7 | #FF000000 8 | #FFFFFFFF 9 | 10 | #F1F1F1 11 | #CCF1F1F1 12 | #C4C4C4 13 | #AAA497 14 | 15 | 16 | #F2EAD6 17 | #0061FF 18 | #1849F6 19 | #73FAEFD5 20 | #2381CC 21 | 22 | -------------------------------------------------------------------------------- /core/src/main/res/drawable/ic_baseline_android_24.xml: -------------------------------------------------------------------------------- 1 | 7 | 10 | 11 | -------------------------------------------------------------------------------- /app/src/main/res/values-night/themes.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 17 | -------------------------------------------------------------------------------- /app/src/main/java/com/nthlink/android/client/storage/sql/ClickedNewsDao.kt: -------------------------------------------------------------------------------- 1 | package com.nthlink.android.client.storage.sql 2 | 3 | import androidx.room.Dao 4 | import androidx.room.Insert 5 | import androidx.room.Query 6 | 7 | @Dao 8 | interface ClickedNewsDao { 9 | @Insert 10 | suspend fun insertAll(records: List) 11 | 12 | @Query("SELECT category, COUNT(*) as count FROM clicked_news WHERE :currentTimestamp - timestamp <= :periodTimestamp GROUP BY category") 13 | suspend fun getCategoryCountsIn( 14 | currentTimestamp: Long = System.currentTimeMillis(), 15 | periodTimestamp: Long 16 | ): List 17 | 18 | @Query("DELETE FROM clicked_news WHERE :currentTimestamp - timestamp > :periodTimestamp") 19 | suspend fun deleteIn(currentTimestamp: Long = System.currentTimeMillis(), periodTimestamp: Long) 20 | } 21 | 22 | data class CategoryCount(val category: String, val count: Int) -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_yt.xml: -------------------------------------------------------------------------------- 1 | 6 | 9 | 10 | -------------------------------------------------------------------------------- /app/src/main/java/com/nthlink/android/client/updates/InAppUpdate.kt: -------------------------------------------------------------------------------- 1 | package com.nthlink.android.client.updates 2 | 3 | import androidx.lifecycle.DefaultLifecycleObserver 4 | import kotlinx.coroutines.flow.MutableSharedFlow 5 | import kotlinx.coroutines.flow.asSharedFlow 6 | 7 | sealed interface InAppUpdateMessage { 8 | data object NewUpdateAvailable : InAppUpdateMessage 9 | data class UpToDate(val notifyUser: Boolean) : InAppUpdateMessage 10 | data class CheckFailed(val notifyUser: Boolean) : InAppUpdateMessage 11 | data object UpdateOk : InAppUpdateMessage 12 | data object UpdateCanceled : InAppUpdateMessage 13 | data object UpdateFailed : InAppUpdateMessage 14 | } 15 | 16 | abstract class InAppUpdate : DefaultLifecycleObserver { 17 | protected val _inAppUpdateFlow = MutableSharedFlow() 18 | val inAppUpdateFlow = _inAppUpdateFlow.asSharedFlow() 19 | 20 | abstract fun checkUpdate(updateIfAvailable: Boolean) 21 | } -------------------------------------------------------------------------------- /app/src/main/res/navigation/launch_graph.xml: -------------------------------------------------------------------------------- 1 | 2 | 7 | 8 | 12 | 13 | 17 | 18 | 19 | 20 | 24 | 25 | -------------------------------------------------------------------------------- /app/src/main/java/com/nthlink/android/client/App.kt: -------------------------------------------------------------------------------- 1 | package com.nthlink.android.client 2 | 3 | import android.app.Application 4 | import androidx.appcompat.app.AppCompatDelegate 5 | import com.nthlink.android.client.di.appModule 6 | import com.nthlink.android.client.storage.sql.AppDatabase 7 | import org.koin.android.ext.koin.androidContext 8 | import org.koin.android.ext.koin.androidLogger 9 | import org.koin.core.context.startKoin 10 | 11 | class App : Application() { 12 | 13 | companion object { 14 | const val TAG = "nthlink_app" 15 | } 16 | 17 | lateinit var db: AppDatabase 18 | private set 19 | 20 | override fun onCreate() { 21 | super.onCreate() 22 | 23 | db = AppDatabase.getInstance(this) 24 | 25 | // disable night mod 26 | AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_NO) 27 | 28 | // koin 29 | startKoin { 30 | androidLogger() 31 | androidContext(this@App) 32 | modules(appModule) 33 | } 34 | } 35 | } -------------------------------------------------------------------------------- /app/src/main/java/com/nthlink/android/client/ui/common/BindingFragment.kt: -------------------------------------------------------------------------------- 1 | package com.nthlink.android.client.ui.common 2 | 3 | import android.os.Bundle 4 | import android.view.LayoutInflater 5 | import android.view.View 6 | import android.view.ViewGroup 7 | import androidx.fragment.app.Fragment 8 | import androidx.viewbinding.ViewBinding 9 | 10 | abstract class BindingFragment : Fragment() { 11 | 12 | private var _binding: T? = null 13 | protected val binding get() = _binding!! 14 | 15 | abstract fun bindView(inflater: LayoutInflater, container: ViewGroup?): T 16 | 17 | override fun onCreateView( 18 | inflater: LayoutInflater, 19 | container: ViewGroup?, 20 | savedInstanceState: Bundle? 21 | ): View { 22 | _binding = bindView(inflater, container) 23 | return binding.root 24 | } 25 | 26 | override fun onDestroyView() { 27 | super.onDestroyView() 28 | _binding = null 29 | } 30 | 31 | protected fun isBindingNotNull(): Boolean = _binding != null 32 | } -------------------------------------------------------------------------------- /app/src/main/res/values/attrs.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | -------------------------------------------------------------------------------- /app/src/main/java/com/nthlink/android/client/storage/datastore/ApkDownloadDataStore.kt: -------------------------------------------------------------------------------- 1 | package com.nthlink.android.client.storage.datastore 2 | 3 | import android.content.Context 4 | import androidx.datastore.core.DataStore 5 | import androidx.datastore.preferences.core.Preferences 6 | import androidx.datastore.preferences.core.longPreferencesKey 7 | import androidx.datastore.preferences.preferencesDataStore 8 | 9 | private val Context.apkDownloadDataStore: DataStore by preferencesDataStore(name = "apk-download-prefs") 10 | 11 | class ApkDownloadDataStore(private val context: Context) { 12 | companion object { 13 | private val APK_DOWNLOAD_ID = longPreferencesKey("apkDownloadId") 14 | } 15 | 16 | suspend fun saveApkDownloadId(downloadId: Long) { 17 | context.apkDownloadDataStore.save(APK_DOWNLOAD_ID, downloadId) 18 | } 19 | 20 | suspend fun readApkDownloadId(): Long? { 21 | return context.apkDownloadDataStore.read(APK_DOWNLOAD_ID) 22 | } 23 | 24 | suspend fun removeApkDownloadId() { 25 | context.apkDownloadDataStore.remove(APK_DOWNLOAD_ID) 26 | } 27 | } -------------------------------------------------------------------------------- /app/src/main/java/com/nthlink/android/client/ui/web/WebUtils.kt: -------------------------------------------------------------------------------- 1 | package com.nthlink.android.client.ui.web 2 | 3 | import android.webkit.WebChromeClient 4 | import android.webkit.WebView 5 | 6 | const val CUSTOM_USER_AGENT = 7 | "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Mobile Safari/537.36" 8 | val customExtraHeaders = mapOf("X-Requested-With" to "XMLHttpRequest") 9 | 10 | class CustomWebChromeClient(private val callback: Callback) : WebChromeClient() { 11 | override fun onProgressChanged(view: WebView, newProgress: Int) { 12 | callback.onProgressChanged(view, newProgress) 13 | 14 | when (newProgress) { 15 | in 0..49 -> callback.onStartLoading(view) 16 | in 50..99 -> callback.onLoading(view) 17 | 100 -> callback.onFinishLoading(view) 18 | } 19 | } 20 | 21 | interface Callback { 22 | fun onProgressChanged(view: WebView, newProgress: Int) {} 23 | fun onStartLoading(view: WebView) {} 24 | fun onLoading(view: WebView) {} 25 | fun onFinishLoading(view: WebView) {} 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /app/src/main/res/layout/view_holder_notification.xml: -------------------------------------------------------------------------------- 1 | 2 | 12 | 13 | 17 | 18 | 26 | 27 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_nthlink.xml: -------------------------------------------------------------------------------- 1 | 6 | 7 | 11 | 12 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_book.xml: -------------------------------------------------------------------------------- 1 | 6 | 9 | 10 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_to_expand.xml: -------------------------------------------------------------------------------- 1 | 6 | 9 | 10 | -------------------------------------------------------------------------------- /core/src/main/java/com/nthlink/android/core/model/DiagnosisReport.kt: -------------------------------------------------------------------------------- 1 | package com.nthlink.android.core.model 2 | 3 | import com.nthlink.android.core.utils.EMPTY 4 | import com.nthlink.android.core.utils.nowInUtc 5 | import kotlinx.serialization.SerialName 6 | import kotlinx.serialization.Serializable 7 | import kotlinx.serialization.Transient 8 | 9 | @Serializable 10 | class DiagnosisReport { 11 | @SerialName("timestamp") 12 | val timestamp: String = nowInUtc() 13 | 14 | @SerialName("message") 15 | var message: String = EMPTY 16 | 17 | @SerialName("countryCode") 18 | var countryCode: String = EMPTY 19 | 20 | @SerialName("carrierName") 21 | var carrierName: String = EMPTY 22 | 23 | @SerialName("networkType") 24 | var networkType: String = EMPTY 25 | 26 | @SerialName("ipBeforeConnecting") 27 | var ipBeforeConnecting: String = EMPTY 28 | 29 | @Transient 30 | private val _getConfigErrors: MutableList = mutableListOf() 31 | 32 | @SerialName("getConfigErrors") 33 | val getConfigErrors: List get() = _getConfigErrors 34 | 35 | @SerialName("ipAfterConnecting") 36 | var ipAfterConnecting: String = EMPTY 37 | 38 | fun add(error: RequestResult) { 39 | _getConfigErrors.add(error) 40 | } 41 | } -------------------------------------------------------------------------------- /app/src/main/java/com/nthlink/android/client/ui/connection/NewsAdapter.kt: -------------------------------------------------------------------------------- 1 | package com.nthlink.android.client.ui.connection 2 | 3 | import android.view.ViewGroup 4 | import androidx.recyclerview.widget.DiffUtil 5 | import androidx.recyclerview.widget.ListAdapter 6 | 7 | class NewsAdapter : ListAdapter(this) { 8 | 9 | companion object : DiffUtil.ItemCallback() { 10 | override fun areItemsTheSame(oldItem: NewsModel, newItem: NewsModel): Boolean { 11 | return (oldItem.viewType == newItem.viewType) 12 | && (oldItem.title == newItem.title) 13 | && (oldItem.url == newItem.url) 14 | } 15 | 16 | override fun areContentsTheSame(oldItem: NewsModel, newItem: NewsModel): Boolean { 17 | return oldItem == newItem 18 | } 19 | } 20 | 21 | var onNewsItemClick: ((NewsModel) -> Unit)? = null 22 | 23 | override fun getItemViewType(position: Int): Int = getItem(position).viewType 24 | 25 | override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): NewsViewHolder { 26 | return getNewsViewHolder(parent, viewType) 27 | } 28 | 29 | override fun onBindViewHolder(holder: NewsViewHolder, position: Int) { 30 | holder.bind(getItem(position), position, onNewsItemClick) 31 | } 32 | } -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_nthlink_logo_blue.xml: -------------------------------------------------------------------------------- 1 | 6 | 10 | 14 | 15 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_nthlink_logo_white.xml: -------------------------------------------------------------------------------- 1 | 6 | 10 | 14 | 15 | -------------------------------------------------------------------------------- /core/src/main/java/com/nthlink/android/core/storage/Storage.kt: -------------------------------------------------------------------------------- 1 | package com.nthlink.android.core.storage 2 | 3 | import android.content.Context 4 | import androidx.datastore.preferences.core.stringPreferencesKey 5 | import com.nthlink.android.core.model.Config 6 | import com.nthlink.android.core.utils.EMPTY 7 | import com.nthlink.android.core.utils.JsonParser 8 | import java.util.UUID 9 | 10 | // Keys 11 | private val keyConfig = stringPreferencesKey("config") 12 | private val keyClientId = stringPreferencesKey("clientId") 13 | 14 | // Preferences DataStore 15 | internal suspend fun saveConfig(context: Context, config: Config) { 16 | val json = JsonParser.toJson(config.copy(servers = emptyList(), customConfig = EMPTY)) 17 | context.rootPrefs.secureSave(keyConfig, json) 18 | } 19 | 20 | internal suspend fun readConfig(context: Context): Config? { 21 | val json = context.rootPrefs.secureRead(keyConfig) ?: EMPTY 22 | return if (json.isEmpty()) null else JsonParser.toConfig(json) 23 | } 24 | 25 | internal suspend fun saveClientId(context: Context, clientId: String) { 26 | context.rootPrefs.secureSave(keyClientId, clientId) 27 | } 28 | 29 | internal suspend fun readClientId(context: Context): String { 30 | return context.rootPrefs.secureRead(keyClientId) ?: run { 31 | val clientId = UUID.randomUUID().toString() 32 | saveClientId(context, clientId) 33 | clientId 34 | } 35 | } -------------------------------------------------------------------------------- /app/src/main/java/com/nthlink/android/client/utils/ActivityX.kt: -------------------------------------------------------------------------------- 1 | package com.nthlink.android.client.utils 2 | 3 | import androidx.annotation.StringRes 4 | import androidx.appcompat.app.AppCompatActivity 5 | import com.nthlink.android.client.R 6 | 7 | fun AppCompatActivity.showAlertDialog( 8 | @StringRes titleRes: Int, 9 | @StringRes messageRes: Int, 10 | cancelable: Boolean = false 11 | ) { 12 | showMaterialAlertDialog(this) { 13 | setTitle(titleRes) 14 | setMessage(messageRes) 15 | setCancelable(cancelable) 16 | setPositiveButton(R.string.ok) { dialog, _ -> dialog.dismiss() } 17 | } 18 | } 19 | 20 | fun AppCompatActivity.showAlertDialog( 21 | @StringRes titleRes: Int, 22 | @StringRes messageRes: Int, 23 | cancelable: Boolean = false, 24 | okListener: () -> Unit 25 | ) { 26 | showMaterialAlertDialog(this) { 27 | setTitle(titleRes) 28 | setMessage(messageRes) 29 | setCancelable(cancelable) 30 | setPositiveButton(R.string.ok) { dialog, _ -> 31 | okListener() 32 | dialog.dismiss() 33 | } 34 | setNegativeButton(R.string.cancel) { dialog, _ -> dialog.dismiss() } 35 | } 36 | } 37 | 38 | fun AppCompatActivity.openWebPage(url: String) { 39 | val intent = getLoadWebUrlIntent(url) 40 | if (intent.resolveActivity(packageManager) != null) { 41 | startActivity(intent) 42 | } 43 | } -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_launcher_foreground.xml: -------------------------------------------------------------------------------- 1 | 6 | 11 | 15 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /gradle.properties: -------------------------------------------------------------------------------- 1 | # Project-wide Gradle settings. 2 | # IDE (e.g. Android Studio) users: 3 | # Gradle settings configured through the IDE *will override* 4 | # any settings specified in this file. 5 | # For more details on how to configure your build environment visit 6 | # http://www.gradle.org/docs/current/userguide/build_environment.html 7 | # Specifies the JVM arguments used for the daemon process. 8 | # The setting is particularly useful for tweaking memory settings. 9 | org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 10 | # When configured, Gradle will run in incubating parallel mode. 11 | # This option should only be used with decoupled projects. More details, visit 12 | # http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects 13 | # org.gradle.parallel=true 14 | # AndroidX package structure to make it clearer which packages are bundled with the 15 | # Android operating system, and which are packaged with your app's APK 16 | # https://developer.android.com/topic/libraries/support-library/androidx-rn 17 | android.useAndroidX=true 18 | # Kotlin code style for this project: "official" or "obsolete": 19 | kotlin.code.style=official 20 | # Enables namespacing of each library's R class so that its R class includes only the 21 | # resources declared in the library itself and none from the library's dependencies, 22 | # thereby reducing the size of the R class for that library 23 | android.nonTransitiveRClass=true -------------------------------------------------------------------------------- /core/src/main/java/com/nthlink/android/core/storage/DataStore.kt: -------------------------------------------------------------------------------- 1 | package com.nthlink.android.core.storage 2 | 3 | import android.content.Context 4 | import androidx.datastore.core.DataStore 5 | import androidx.datastore.preferences.core.Preferences 6 | import androidx.datastore.preferences.core.edit 7 | import androidx.datastore.preferences.preferencesDataStore 8 | import com.nthlink.android.core.Core 9 | import kotlinx.coroutines.flow.first 10 | 11 | /** 12 | * Preferences DataStore 13 | */ 14 | 15 | internal val Context.rootPrefs: DataStore by preferencesDataStore(name = "root-prefs") 16 | 17 | internal suspend fun DataStore.save(key: Preferences.Key, value: T) { 18 | edit { prefs -> prefs[key] = value } 19 | } 20 | 21 | internal suspend fun DataStore.read(key: Preferences.Key): T? { 22 | return data.first()[key] 23 | } 24 | 25 | internal suspend fun DataStore.remove(key: Preferences.Key) { 26 | edit { prefs -> prefs.remove(key) } 27 | } 28 | 29 | internal suspend fun DataStore.secureSave( 30 | key: Preferences.Key, 31 | value: String 32 | ) { 33 | val encryptedValue = Core.encrypt(value) 34 | save(key, encryptedValue) 35 | } 36 | 37 | internal suspend fun DataStore.secureRead(key: Preferences.Key): String? { 38 | val encryptedValue = read(key) 39 | return encryptedValue?.let { Core.decrypt(it) } 40 | } -------------------------------------------------------------------------------- /app/src/main/res/menu/navigation_drawer.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 7 | 8 | 11 | 12 | 15 | 16 | 19 | 20 | 23 | 24 | 27 | 28 | 31 | 32 | 35 | 36 | 39 | 40 | 43 | 44 | 47 | 48 | -------------------------------------------------------------------------------- /core/build.gradle: -------------------------------------------------------------------------------- 1 | plugins { 2 | id 'com.android.library' 3 | id 'org.jetbrains.kotlin.android' 4 | id 'kotlinx-serialization' 5 | } 6 | 7 | android { 8 | namespace 'com.nthlink.android.core' 9 | compileSdk 36 10 | 11 | defaultConfig { 12 | minSdk 26 13 | 14 | testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" 15 | consumerProguardFiles "consumer-rules.pro" 16 | } 17 | 18 | buildTypes { 19 | release { 20 | minifyEnabled false 21 | proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' 22 | } 23 | } 24 | compileOptions { 25 | sourceCompatibility JavaVersion.VERSION_11 26 | targetCompatibility JavaVersion.VERSION_11 27 | } 28 | kotlinOptions { 29 | jvmTarget = '11' 30 | } 31 | buildFeatures { 32 | buildConfig = true 33 | } 34 | } 35 | 36 | dependencies { 37 | // AndroidX 38 | implementation 'androidx.core:core-ktx:1.17.0' 39 | implementation 'androidx.appcompat:appcompat:1.7.1' 40 | implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.9.4' 41 | implementation 'androidx.datastore:datastore-preferences:1.1.4' 42 | 43 | // Jetbrains 44 | implementation 'org.jetbrains.kotlinx:kotlinx-serialization-json:1.9.0' 45 | 46 | testImplementation 'junit:junit:4.13.2' 47 | androidTestImplementation 'androidx.test.ext:junit:1.3.0' 48 | androidTestImplementation 'androidx.test.espresso:espresso-core:3.7.0' 49 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | BSD 3-Clause License 2 | 3 | Copyright (c) 2024, nthLink 4 | 5 | Redistribution and use in source and binary forms, with or without 6 | modification, are permitted provided that the following conditions are met: 7 | 8 | 1. Redistributions of source code must retain the above copyright notice, this 9 | list of conditions and the following disclaimer. 10 | 11 | 2. Redistributions in binary form must reproduce the above copyright notice, 12 | this list of conditions and the following disclaimer in the documentation 13 | and/or other materials provided with the distribution. 14 | 15 | 3. Neither the name of the copyright holder nor the names of its 16 | contributors may be used to endorse or promote products derived from 17 | this software without specific prior written permission. 18 | 19 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 20 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 21 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 22 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 23 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 24 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 25 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 26 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 27 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 28 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 29 | -------------------------------------------------------------------------------- /app/src/main/res/layout/fragment_launch.xml: -------------------------------------------------------------------------------- 1 | 2 | 8 | 9 | 20 | 21 | 33 | 34 | -------------------------------------------------------------------------------- /app/src/main/java/com/nthlink/android/client/ui/privacy/PrivacyFragment.kt: -------------------------------------------------------------------------------- 1 | package com.nthlink.android.client.ui.privacy 2 | 3 | import android.os.Bundle 4 | import android.view.LayoutInflater 5 | import android.view.View 6 | import android.view.ViewGroup 7 | import androidx.lifecycle.lifecycleScope 8 | import com.nthlink.android.client.R 9 | import com.nthlink.android.client.databinding.FragmentPrivacyBinding 10 | import com.nthlink.android.client.storage.datastore.CommonDataStore 11 | import com.nthlink.android.client.ui.LaunchActivity 12 | import com.nthlink.android.client.ui.common.BindingFragment 13 | import com.nthlink.android.client.utils.openWebPage 14 | import kotlinx.coroutines.launch 15 | import org.koin.android.ext.android.inject 16 | 17 | class PrivacyFragment : BindingFragment() { 18 | private val commonDataStore: CommonDataStore by inject() 19 | 20 | override fun bindView(inflater: LayoutInflater, container: ViewGroup?): FragmentPrivacyBinding { 21 | return FragmentPrivacyBinding.inflate(inflater, container, false) 22 | } 23 | 24 | override fun onViewCreated(view: View, savedInstanceState: Bundle?) { 25 | super.onViewCreated(view, savedInstanceState) 26 | 27 | binding.privacySubmit.setOnClickListener { 28 | lifecycleScope.launch { 29 | commonDataStore.saveAgreePrivacy(true) 30 | (requireActivity() as LaunchActivity).moveToMainActivity() 31 | } 32 | } 33 | 34 | binding.privacyPolicy.setOnClickListener { openWebPage(getString(R.string.url_policies)) } 35 | } 36 | } -------------------------------------------------------------------------------- /app/src/main/java/com/nthlink/android/client/ui/launch/LaunchFragment.kt: -------------------------------------------------------------------------------- 1 | package com.nthlink.android.client.ui.launch 2 | 3 | import android.os.Bundle 4 | import android.view.LayoutInflater 5 | import android.view.View 6 | import android.view.ViewGroup 7 | import androidx.lifecycle.lifecycleScope 8 | import androidx.navigation.fragment.findNavController 9 | import com.nthlink.android.client.databinding.FragmentLaunchBinding 10 | import com.nthlink.android.client.storage.datastore.CommonDataStore 11 | import com.nthlink.android.client.ui.LaunchActivity 12 | import com.nthlink.android.client.ui.common.BindingFragment 13 | import kotlinx.coroutines.delay 14 | import kotlinx.coroutines.launch 15 | import org.koin.android.ext.android.inject 16 | 17 | class LaunchFragment : BindingFragment() { 18 | private val commonDataStore: CommonDataStore by inject() 19 | 20 | override fun bindView(inflater: LayoutInflater, container: ViewGroup?): FragmentLaunchBinding { 21 | return FragmentLaunchBinding.inflate(inflater, container, false) 22 | } 23 | 24 | override fun onViewCreated(view: View, savedInstanceState: Bundle?) { 25 | super.onViewCreated(view, savedInstanceState) 26 | 27 | lifecycleScope.launch { 28 | delay(2000) 29 | if (commonDataStore.readAgreePrivacy()) { 30 | (requireActivity() as LaunchActivity).moveToMainActivity() 31 | } else { 32 | findNavController().navigate( 33 | LaunchFragmentDirections.actionLaunchFragmentToPrivacyFragment() 34 | ) 35 | } 36 | } 37 | } 38 | } -------------------------------------------------------------------------------- /app/src/main/res/layout/view_holder_news_title.xml: -------------------------------------------------------------------------------- 1 | 2 | 9 | 10 | 24 | 25 | 35 | 36 | -------------------------------------------------------------------------------- /app/src/main/res/layout/layout_toolbar.xml: -------------------------------------------------------------------------------- 1 | 2 | 7 | 8 | 14 | 15 | 22 | 23 | 27 | 28 | 36 | 37 | 38 | 39 | 40 | 41 | -------------------------------------------------------------------------------- /app/src/main/res/values/themes.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 17 | 18 | 21 | 22 | 26 | 27 | 31 | 32 | 36 | 37 | -------------------------------------------------------------------------------- /app/src/main/res/layout/fragment_web.xml: -------------------------------------------------------------------------------- 1 | 2 | 9 | 10 | 19 | 20 | 28 | 29 | 37 | 38 | 39 | -------------------------------------------------------------------------------- /core/src/main/java/com/nthlink/android/core/utils/Utils.kt: -------------------------------------------------------------------------------- 1 | package com.nthlink.android.core.utils 2 | 3 | import android.content.Context 4 | import android.net.NetworkCapabilities.TRANSPORT_BLUETOOTH 5 | import android.net.NetworkCapabilities.TRANSPORT_CELLULAR 6 | import android.net.NetworkCapabilities.TRANSPORT_ETHERNET 7 | import android.net.NetworkCapabilities.TRANSPORT_WIFI 8 | import java.time.OffsetDateTime 9 | import java.time.ZoneOffset 10 | import java.time.ZonedDateTime 11 | import java.time.format.DateTimeFormatter 12 | 13 | // Constants 14 | internal const val TAG = "RootVpn" 15 | const val EMPTY = "" 16 | const val ZERO = 0 17 | const val NO_RESOURCE = ZERO 18 | 19 | // Patterns for formatting 20 | internal const val PATTERN_LOCALIZED_ZONE_OFFSET = "O" 21 | internal const val PATTERN_DATE_TIME = "yyyy-MM-dd HH:mm:ss" 22 | 23 | internal fun getTimeZoneAbbreviation(): String { 24 | return ZonedDateTime.now().format(DateTimeFormatter.ofPattern(PATTERN_LOCALIZED_ZONE_OFFSET)) 25 | } 26 | 27 | internal fun nowInUtc(pattern: String = PATTERN_DATE_TIME): String { 28 | return OffsetDateTime.now(ZoneOffset.UTC).format(DateTimeFormatter.ofPattern(pattern)) 29 | } 30 | 31 | fun isOnline(context: Context): Boolean = context.getConnectivityManager().run { 32 | val network = activeNetwork ?: return false 33 | val networkCapabilities = getNetworkCapabilities(network) ?: return false 34 | return@run when { 35 | networkCapabilities.hasTransport(TRANSPORT_WIFI) -> true 36 | networkCapabilities.hasTransport(TRANSPORT_CELLULAR) -> true 37 | // for other device how are able to connect with Ethernet 38 | networkCapabilities.hasTransport(TRANSPORT_ETHERNET) -> true 39 | // for check internet over Bluetooth 40 | networkCapabilities.hasTransport(TRANSPORT_BLUETOOTH) -> true 41 | else -> false 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /app/src/main/res/layout/bottom_sheet_switch.xml: -------------------------------------------------------------------------------- 1 | 2 | 11 | 12 | 19 | 20 | 38 | 39 | -------------------------------------------------------------------------------- /app/src/main/java/com/nthlink/android/client/storage/datastore/CommonDataStore.kt: -------------------------------------------------------------------------------- 1 | package com.nthlink.android.client.storage.datastore 2 | 3 | import android.content.Context 4 | import androidx.datastore.core.DataStore 5 | import androidx.datastore.preferences.core.Preferences 6 | import androidx.datastore.preferences.core.booleanPreferencesKey 7 | import androidx.datastore.preferences.core.intPreferencesKey 8 | import androidx.datastore.preferences.preferencesDataStore 9 | import com.nthlink.android.core.utils.ZERO 10 | 11 | private val Context.commonDataStore: DataStore by preferencesDataStore(name = "nthlink-prefs") 12 | 13 | class CommonDataStore(private val context: Context) { 14 | 15 | companion object { 16 | private val AGREE_PRIVACY = booleanPreferencesKey("agreePrivacy") 17 | private val CONNECTED_COUNT = intPreferencesKey("connectedCount") 18 | private val HAS_LANDING_PAGE_SHOWN = booleanPreferencesKey("hasLandingPageShown") 19 | } 20 | 21 | // Preferences DataStore 22 | suspend fun saveAgreePrivacy(agreePrivacy: Boolean) { 23 | context.commonDataStore.save(AGREE_PRIVACY, agreePrivacy) 24 | } 25 | 26 | suspend fun readAgreePrivacy(): Boolean { 27 | return context.commonDataStore.read(AGREE_PRIVACY) ?: false 28 | } 29 | 30 | suspend fun saveConnectedCount() { 31 | val count = readConnectedCount() 32 | context.commonDataStore.save(CONNECTED_COUNT, count + 1) 33 | } 34 | 35 | suspend fun readConnectedCount(): Int { 36 | return context.commonDataStore.read(CONNECTED_COUNT) ?: ZERO 37 | } 38 | 39 | suspend fun saveHasLandingPageShown(hasShown: Boolean) { 40 | context.commonDataStore.save(HAS_LANDING_PAGE_SHOWN, hasShown) 41 | } 42 | 43 | suspend fun readHasLandingPageShown(): Boolean { 44 | return context.commonDataStore.read(HAS_LANDING_PAGE_SHOWN) ?: false 45 | } 46 | } -------------------------------------------------------------------------------- /app/src/main/java/com/nthlink/android/client/ui/connection/SwitchBottomSheet.kt: -------------------------------------------------------------------------------- 1 | package com.nthlink.android.client.ui.connection 2 | 3 | import android.view.View 4 | import com.google.android.material.bottomsheet.BottomSheetBehavior 5 | import com.google.android.material.bottomsheet.BottomSheetBehavior.BottomSheetCallback 6 | import com.google.android.material.bottomsheet.BottomSheetBehavior.STATE_COLLAPSED 7 | import com.google.android.material.bottomsheet.BottomSheetBehavior.STATE_EXPANDED 8 | import com.nthlink.android.client.databinding.BottomSheetSwitchBinding 9 | 10 | 11 | class SwitchBottomSheet( 12 | private var _binding: BottomSheetSwitchBinding?, 13 | private var onExpanded: (() -> Unit)? 14 | ) : BottomSheetCallback() { 15 | private val binding get() = _binding!! 16 | 17 | private val behavior = BottomSheetBehavior.from(binding.root).apply { 18 | addBottomSheetCallback(this@SwitchBottomSheet) 19 | } 20 | 21 | var isDraggable: Boolean 22 | set(value) { 23 | behavior.isDraggable = value 24 | } 25 | get() = behavior.isDraggable 26 | 27 | 28 | init { 29 | binding.dragHandle.post { 30 | _binding?.dragHandle?.let { behavior.peekHeight = it.height } 31 | } 32 | } 33 | 34 | override fun onStateChanged(bottomSheet: View, newState: Int) { 35 | if (newState == STATE_EXPANDED) onExpanded?.invoke() 36 | } 37 | 38 | override fun onSlide(bottomSheet: View, slideOffset: Float) {} 39 | 40 | fun collapse() { 41 | behavior.state = STATE_COLLAPSED 42 | } 43 | 44 | fun expand() { 45 | behavior.state = STATE_EXPANDED 46 | } 47 | 48 | fun toggle() { 49 | behavior.state = if (behavior.state == STATE_COLLAPSED) STATE_EXPANDED else STATE_COLLAPSED 50 | } 51 | 52 | fun onDestroyView() { 53 | onExpanded = null 54 | behavior.removeBottomSheetCallback(this) 55 | } 56 | } -------------------------------------------------------------------------------- /app/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 16 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 30 | 31 | 34 | 35 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_tg.xml: -------------------------------------------------------------------------------- 1 | 7 | 8 | 9 | 15 | 18 | 21 | 22 | 23 | 24 | 27 | 28 | -------------------------------------------------------------------------------- /app/src/main/java/com/nthlink/android/client/updates/ApkDownloadReceiver.kt: -------------------------------------------------------------------------------- 1 | package com.nthlink.android.client.updates 2 | 3 | import android.app.DownloadManager 4 | import android.content.BroadcastReceiver 5 | import android.content.Context 6 | import android.content.Intent 7 | import android.util.Log 8 | import com.nthlink.android.client.App 9 | import com.nthlink.android.client.storage.datastore.ApkDownloadDataStore 10 | import com.nthlink.android.client.ui.MainActivity 11 | import kotlinx.coroutines.CoroutineScope 12 | import kotlinx.coroutines.Dispatchers 13 | import kotlinx.coroutines.launch 14 | import org.koin.core.component.KoinComponent 15 | import org.koin.core.component.inject 16 | 17 | class ApkDownloadReceiver : BroadcastReceiver(), KoinComponent { 18 | companion object { 19 | const val ACTION_OPEN_APK_UPDATE = "action_open_apk_update" 20 | } 21 | 22 | private val dataStore: ApkDownloadDataStore by inject() 23 | 24 | override fun onReceive(context: Context, intent: Intent) { 25 | when (intent.action) { 26 | DownloadManager.ACTION_NOTIFICATION_CLICKED -> { 27 | Log.d(App.Companion.TAG, "Download notification clicked") 28 | 29 | // goAsync() extends BroadcastReceiver execution time for async operations 30 | val pendingResult = goAsync() 31 | CoroutineScope(Dispatchers.IO).launch { 32 | try { 33 | if (dataStore.readApkDownloadId() == null) return@launch 34 | 35 | val intent = Intent(context, MainActivity::class.java).apply { 36 | action = ACTION_OPEN_APK_UPDATE 37 | flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP 38 | } 39 | context.startActivity(intent) 40 | } finally { 41 | pendingResult.finish() 42 | } 43 | } 44 | } 45 | } 46 | } 47 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # nthlink Android App 2 | 3 | An open-source VPN app framework built with [Leaf](https://github.com/eycorsican/leaf) 4 | and [nthlink-outline](https://github.com/nthlink/nthlink-outline) VPN SDKs. This project provides a 5 | complete Android VPN app template - you just need to implement the core VPN logic and backend 6 | integration. 7 | 8 | ## Quick Start 9 | 10 | Search for `TODO` in the project to find all implementation points. You need to implement **2 files 11 | ** in the `core` module: 12 | 13 | ### 1. Backend Integration (`core/.../Core.kt`) 14 | 15 | Implement these 5 functions: 16 | 17 | ```kotlin 18 | // Encrypt/decrypt data for secure storage 19 | fun encrypt(text: String): String 20 | fun decrypt(cipherText: String): String 21 | 22 | // Fetch VPN servers and app content from your backend 23 | // Returns JSON with: servers, redirectUrl, headlineNews, notifications, current_versions 24 | fun getConfig(): String 25 | 26 | // Send user feedback to your backend 27 | fun feedback(feedbackType: String, description: String, appVersion: String, email: String) 28 | 29 | // Run diagnostics and return a report ID 30 | fun startDiagnostics(): String 31 | ``` 32 | 33 | ### 2. VPN Client (`core/.../RootVpnClient.kt`) 34 | 35 | Implement these 3 functions: 36 | 37 | ```kotlin 38 | // Start VPN with server list (auto-select best server) 39 | override suspend fun runVpn(servers: List) 40 | 41 | // Start VPN with custom config string 42 | override suspend fun runVpn(config: String) 43 | 44 | // Stop VPN and clean up resources 45 | override fun disconnect() 46 | ``` 47 | 48 | ## Implementation Steps 49 | 50 | 1. Clone the repository 51 | 2. Search for `TODO` markers in the code 52 | 3. Implement `Core.kt` backend functions 53 | 4. Implement `RootVpnClient.kt` VPN functions using Leaf/Outline SDK 54 | 5. Test connection, disconnection, and error handling 55 | 6. Customize branding (app name, icon, colors, strings) 56 | 7. Build and release your VPN app 57 | 58 | ## Learn More 59 | 60 | - [Leaf VPN SDK](https://github.com/eycorsican/leaf) 61 | - [nthlink-outline](https://github.com/nthlink/nthlink-outline) 62 | - [Outline VPN](https://getoutline.org/) -------------------------------------------------------------------------------- /app/src/main/res/layout/fragment_about.xml: -------------------------------------------------------------------------------- 1 | 2 | 7 | 8 | 15 | 16 | 25 | 26 | 31 | 32 | 39 | 40 | 49 | 50 | 51 | 52 | 53 | 54 | -------------------------------------------------------------------------------- /app/src/main/res/navigation/main_graph.xml: -------------------------------------------------------------------------------- 1 | 2 | 7 | 8 | 12 | 15 | 16 | 17 | 22 | 23 | 28 | 29 | 34 | 37 | 38 | 39 | 44 | 45 | 50 | 51 | 56 | 59 | 62 | 63 | 64 | 65 | 66 | -------------------------------------------------------------------------------- /app/build.gradle: -------------------------------------------------------------------------------- 1 | plugins { 2 | id 'com.android.application' 3 | id 'org.jetbrains.kotlin.android' 4 | id 'androidx.navigation.safeargs.kotlin' 5 | id 'kotlinx-serialization' 6 | id 'com.google.devtools.ksp' 7 | } 8 | 9 | android { 10 | namespace 'com.nthlink.android.client' 11 | compileSdk = 36 12 | 13 | defaultConfig { 14 | applicationId "com.nthlink.android.client" 15 | minSdk 26 16 | targetSdk 36 17 | versionCode 1 18 | versionName "6.8.0" 19 | 20 | testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" 21 | } 22 | 23 | buildTypes { 24 | release { 25 | minifyEnabled false 26 | proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' 27 | } 28 | } 29 | compileOptions { 30 | sourceCompatibility JavaVersion.VERSION_11 31 | targetCompatibility JavaVersion.VERSION_11 32 | } 33 | kotlinOptions { 34 | jvmTarget = '11' 35 | } 36 | buildFeatures { 37 | viewBinding true 38 | buildConfig = true 39 | } 40 | } 41 | 42 | dependencies { 43 | // Android 44 | implementation 'androidx.core:core-ktx:1.17.0' 45 | implementation 'androidx.appcompat:appcompat:1.7.1' 46 | implementation 'androidx.constraintlayout:constraintlayout:2.2.1' 47 | implementation 'androidx.navigation:navigation-fragment-ktx:2.9.5' 48 | implementation 'androidx.navigation:navigation-ui-ktx:2.9.5' 49 | implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.9.4' 50 | implementation 'androidx.datastore:datastore-preferences:1.1.4' 51 | implementation 'androidx.drawerlayout:drawerlayout:1.2.0' 52 | implementation "androidx.work:work-runtime-ktx:2.11.0" 53 | 54 | def room_version = "2.8.3" 55 | implementation "androidx.room:room-ktx:$room_version" 56 | ksp "androidx.room:room-compiler:$room_version" 57 | 58 | // Jetbrains 59 | implementation 'org.jetbrains.kotlinx:kotlinx-serialization-json:1.9.0' 60 | 61 | // Google 62 | implementation 'com.google.android.material:material:1.13.0' 63 | implementation 'com.google.android.play:review-ktx:2.0.2' 64 | implementation 'com.google.android.play:app-update-ktx:2.1.0' 65 | implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-play-services:1.10.2' 66 | 67 | // koin 68 | implementation 'io.insert-koin:koin-android:4.1.1' 69 | 70 | implementation project(":core") 71 | 72 | testImplementation 'junit:junit:4.+' 73 | androidTestImplementation 'androidx.test.ext:junit:1.3.0' 74 | androidTestImplementation 'androidx.test.espresso:espresso-core:3.7.0' 75 | } -------------------------------------------------------------------------------- /app/src/main/res/layout/fragment_diagnostic.xml: -------------------------------------------------------------------------------- 1 | 2 | 7 | 8 | 14 | 15 | 20 | 21 | 28 | 29 | 36 | 37 |