├── 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 |
--------------------------------------------------------------------------------
/.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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
43 |
44 |
51 |
52 |
58 |
59 |
60 |
61 |
--------------------------------------------------------------------------------
/app/src/main/java/com/nthlink/android/client/updates/InAppUpdateApk.kt:
--------------------------------------------------------------------------------
1 | package com.nthlink.android.client.updates
2 |
3 | import android.util.Log
4 | import com.nthlink.android.client.App.Companion.TAG
5 | import com.nthlink.android.client.BuildConfig
6 | import com.nthlink.android.core.Root
7 | import kotlinx.coroutines.CoroutineScope
8 | import kotlinx.coroutines.Dispatchers.IO
9 | import kotlinx.coroutines.Dispatchers.Main
10 | import kotlinx.coroutines.launch
11 | import kotlinx.coroutines.withContext
12 | import kotlin.math.max
13 |
14 | class InAppUpdateApk(
15 | private val scope: CoroutineScope,
16 | private val root: Root,
17 | private val navigateToApkUpdate: (version: String, url: String) -> Unit
18 | ) : InAppUpdate() {
19 |
20 | override fun checkUpdate(updateIfAvailable: Boolean) {
21 | scope.launch(IO) {
22 | try {
23 | val androidPlatform = root.getConfig()
24 | ?.currentVersions?.find { it.appName.lowercase() == "nthlink" }
25 | ?.platforms?.find { it.os.lowercase() == "android" }
26 |
27 | val latestVersion = androidPlatform?.version
28 | ?: throw IllegalStateException("latest version not found")
29 |
30 | if (isNewUpdateAvailable(latestVersion)) {
31 | _inAppUpdateFlow.emit(InAppUpdateMessage.NewUpdateAvailable)
32 | if (updateIfAvailable) withContext(Main) {
33 | navigateToApkUpdate(latestVersion, androidPlatform.url)
34 | }
35 | } else {
36 | _inAppUpdateFlow.emit(InAppUpdateMessage.UpToDate(updateIfAvailable))
37 | }
38 | } catch (e: Throwable) {
39 | Log.e(TAG, "checkUpdate error:", e)
40 | _inAppUpdateFlow.emit(InAppUpdateMessage.CheckFailed(updateIfAvailable))
41 | }
42 | }
43 | }
44 |
45 | private fun isNewUpdateAvailable(latestVersion: String): Boolean {
46 | val latestVersionArr = latestVersion.split('.')
47 | val currentVersionArr = BuildConfig.VERSION_NAME.split('.')
48 |
49 | val maxLength = max(latestVersionArr.size, currentVersionArr.size)
50 |
51 | for (i in 0 until maxLength) {
52 | val latestVersionCode = latestVersionArr.getOrNull(i)?.toInt() ?: 0
53 | val currentVersionCode = currentVersionArr.getOrNull(i)?.toInt() ?: 0
54 |
55 | return when {
56 | latestVersionCode > currentVersionCode -> true
57 | latestVersionCode < currentVersionCode -> false
58 | else -> continue
59 | }
60 | }
61 |
62 | return false
63 | }
64 | }
--------------------------------------------------------------------------------
/core/src/main/java/com/nthlink/android/core/Root.kt:
--------------------------------------------------------------------------------
1 | package com.nthlink.android.core
2 |
3 | import android.content.Context
4 | import androidx.activity.ComponentActivity
5 | import androidx.fragment.app.Fragment
6 | import androidx.lifecycle.Lifecycle
7 | import com.nthlink.android.core.model.Config
8 | import com.nthlink.android.core.utils.EMPTY
9 | import kotlinx.coroutines.flow.SharedFlow
10 | import kotlinx.coroutines.flow.StateFlow
11 |
12 | interface Root {
13 | enum class Status {
14 | DISCONNECTED,
15 | INITIALIZING,
16 | CONNECTING,
17 | CONNECTED,
18 | DISCONNECTING
19 | }
20 |
21 | enum class Error {
22 | GET_CONFIG_ERROR,
23 | NO_PROXY_AVAILABLE,
24 |
25 | // leaf
26 | NO_PERMISSION,
27 | NO_INTERNET,
28 | INVALID_CONFIG,
29 | VPN_SERVICE_NOT_EXISTS,
30 | CREATE_TUN_FAILED,
31 | START_LEAF_FAILED
32 | }
33 |
34 | sealed interface DiagnosticResult {
35 | data object ErrNoInternet : DiagnosticResult
36 | data class Ok(val reportId: String) : DiagnosticResult
37 | }
38 |
39 | val statusFlow: StateFlow
40 | val errorFlow: SharedFlow
41 | val status: Status get() = statusFlow.value
42 | val diagnosticResultFlow: SharedFlow
43 |
44 | fun connect(config: String = EMPTY)
45 | fun disconnect()
46 | fun toggle() = if (status == Status.DISCONNECTED) connect() else disconnect()
47 | fun startDiagnostics()
48 | suspend fun getConfig(): Config?
49 |
50 | class Builder {
51 | fun build(context: Context, lifecycle: Lifecycle): Root {
52 | val rootVpnLeaf = RootVpnClient(context)
53 | lifecycle.addObserver(rootVpnLeaf)
54 | return rootVpnLeaf
55 | }
56 |
57 | fun build(activity: ComponentActivity): Root {
58 | return build(activity, activity.lifecycle)
59 | }
60 |
61 | fun build(fragment: Fragment): Root {
62 | return build(fragment.requireContext(), fragment.lifecycle)
63 | }
64 | }
65 |
66 | companion object {
67 | fun getConfig(): String {
68 | return Core.getConfig()
69 | }
70 |
71 | fun feedback(
72 | context: Context,
73 | feedbackType: String,
74 | description: String = EMPTY,
75 | errorCode: String = EMPTY,
76 | errorMessage: String = EMPTY,
77 | appVersion: String = EMPTY,
78 | email: String = EMPTY
79 | ) {
80 | Core.feedback(
81 | feedbackType,
82 | description,
83 | appVersion,
84 | email
85 | )
86 | }
87 | }
88 | }
--------------------------------------------------------------------------------
/app/src/main/res/layout/activity_main.xml:
--------------------------------------------------------------------------------
1 |
2 |
9 |
10 |
14 |
15 |
18 |
19 |
26 |
27 |
28 |
29 |
34 |
35 |
39 |
40 |
47 |
48 |
55 |
56 |
62 |
63 |
64 |
65 |
66 |
67 |
68 |
69 |
70 |
--------------------------------------------------------------------------------
/core/src/main/java/com/nthlink/android/core/model/Config.kt:
--------------------------------------------------------------------------------
1 | package com.nthlink.android.core.model
2 |
3 | import com.nthlink.android.core.utils.EMPTY
4 | import kotlinx.serialization.SerialName
5 | import kotlinx.serialization.Serializable
6 |
7 | @Serializable
8 | data class Config(
9 | @SerialName("servers")
10 | val servers: List = emptyList(),
11 | @SerialName("redirectUrl")
12 | val redirectUrl: String = EMPTY,
13 | @SerialName("headlineNews")
14 | val headlineNews: List = emptyList(),
15 | @SerialName("notifications")
16 | val notifications: List = emptyList(),
17 | @SerialName("data")
18 | val data: String = EMPTY,
19 | @SerialName("static")
20 | val static: Boolean = false,
21 | @SerialName("use_custom_config")
22 | val useCustomConfig: Boolean = false,
23 | @SerialName("custom_config")
24 | val customConfig: String = EMPTY,
25 | @SerialName("current_versions")
26 | val currentVersions: List = emptyList()
27 | ) {
28 | @Serializable
29 | data class Server(
30 | @SerialName("protocol")
31 | val protocol: String,
32 | @SerialName("host")
33 | val host: String,
34 | @SerialName("ips")
35 | val ips: List = emptyList(),
36 | @SerialName("port")
37 | val port: Int,
38 | @SerialName("password")
39 | val password: String,
40 | @SerialName("encrypt_method")
41 | val encryptMethod: String = EMPTY,
42 | @SerialName("sni")
43 | val sni: String = EMPTY,
44 | @SerialName("ws")
45 | val ws: Boolean = false,
46 | @SerialName("ws_path")
47 | val wsPath: String = EMPTY,
48 | @SerialName("ws_host")
49 | val wsHost: String = EMPTY
50 | )
51 |
52 | @Serializable
53 | data class HeadlineNews(
54 | @SerialName("title")
55 | val title: String,
56 | @SerialName("excerpt")
57 | val excerpt: String,
58 | @SerialName("image")
59 | val image: String,
60 | @SerialName("url")
61 | val url: String,
62 | @SerialName("pinToTop")
63 | val pinToTop: Boolean = false,
64 | @SerialName("categories")
65 | val categories: List = emptyList()
66 | )
67 |
68 | @Serializable
69 | data class Notification(
70 | @SerialName("title")
71 | val title: String,
72 | @SerialName("url")
73 | val url: String
74 | )
75 |
76 | @Serializable
77 | data class Version(
78 | @SerialName("app_name")
79 | val appName: String,
80 | @SerialName("platforms")
81 | val platforms: List
82 | )
83 |
84 | @Serializable
85 | data class Platform(
86 | @SerialName("os")
87 | val os: String,
88 | @SerialName("version")
89 | val version: String,
90 | @SerialName("url")
91 | val url: String
92 | )
93 | }
--------------------------------------------------------------------------------
/app/src/main/res/layout/fragment_connection.xml:
--------------------------------------------------------------------------------
1 |
2 |
6 |
7 |
12 |
13 |
24 |
25 |
38 |
39 |
45 |
46 |
55 |
56 |
57 |
58 |
61 |
62 |
--------------------------------------------------------------------------------
/gradlew.bat:
--------------------------------------------------------------------------------
1 | @rem
2 | @rem Copyright 2015 the original author or authors.
3 | @rem
4 | @rem Licensed under the Apache License, Version 2.0 (the "License");
5 | @rem you may not use this file except in compliance with the License.
6 | @rem You may obtain a copy of the License at
7 | @rem
8 | @rem https://www.apache.org/licenses/LICENSE-2.0
9 | @rem
10 | @rem Unless required by applicable law or agreed to in writing, software
11 | @rem distributed under the License is distributed on an "AS IS" BASIS,
12 | @rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | @rem See the License for the specific language governing permissions and
14 | @rem limitations under the License.
15 | @rem
16 |
17 | @if "%DEBUG%" == "" @echo off
18 | @rem ##########################################################################
19 | @rem
20 | @rem Gradle startup script for Windows
21 | @rem
22 | @rem ##########################################################################
23 |
24 | @rem Set local scope for the variables with windows NT shell
25 | if "%OS%"=="Windows_NT" setlocal
26 |
27 | set DIRNAME=%~dp0
28 | if "%DIRNAME%" == "" set DIRNAME=.
29 | set APP_BASE_NAME=%~n0
30 | set APP_HOME=%DIRNAME%
31 |
32 | @rem Resolve any "." and ".." in APP_HOME to make it shorter.
33 | for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
34 |
35 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
36 | set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
37 |
38 | @rem Find java.exe
39 | if defined JAVA_HOME goto findJavaFromJavaHome
40 |
41 | set JAVA_EXE=java.exe
42 | %JAVA_EXE% -version >NUL 2>&1
43 | if "%ERRORLEVEL%" == "0" goto execute
44 |
45 | echo.
46 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
47 | echo.
48 | echo Please set the JAVA_HOME variable in your environment to match the
49 | echo location of your Java installation.
50 |
51 | goto fail
52 |
53 | :findJavaFromJavaHome
54 | set JAVA_HOME=%JAVA_HOME:"=%
55 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe
56 |
57 | if exist "%JAVA_EXE%" goto execute
58 |
59 | echo.
60 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
61 | echo.
62 | echo Please set the JAVA_HOME variable in your environment to match the
63 | echo location of your Java installation.
64 |
65 | goto fail
66 |
67 | :execute
68 | @rem Setup the command line
69 |
70 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
71 |
72 |
73 | @rem Execute Gradle
74 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
75 |
76 | :end
77 | @rem End local scope for the variables with windows NT shell
78 | if "%ERRORLEVEL%"=="0" goto mainEnd
79 |
80 | :fail
81 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
82 | rem the _cmd.exe /c_ return code!
83 | if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
84 | exit /b 1
85 |
86 | :mainEnd
87 | if "%OS%"=="Windows_NT" endlocal
88 |
89 | :omega
90 |
--------------------------------------------------------------------------------
/app/src/main/res/layout/fragment_apk_update.xml:
--------------------------------------------------------------------------------
1 |
2 |
7 |
8 |
14 |
15 |
20 |
21 |
29 |
30 |
38 |
39 |
45 |
46 |
53 |
54 |
61 |
62 |
70 |
71 |
72 |
--------------------------------------------------------------------------------
/app/src/main/java/com/nthlink/android/client/ui/connection/NewsItem.kt:
--------------------------------------------------------------------------------
1 | package com.nthlink.android.client.ui.connection
2 |
3 | import android.view.LayoutInflater
4 | import android.view.View
5 | import android.view.ViewGroup
6 | import androidx.recyclerview.widget.RecyclerView
7 | import com.nthlink.android.client.R
8 | import com.nthlink.android.client.databinding.ViewHolderNewsTitleBinding
9 | import com.nthlink.android.client.databinding.ViewHolderNotificationBinding
10 |
11 | /**
12 | * Model
13 | */
14 | sealed class NewsModel(val viewType: Int, val title: String, val url: String) {
15 |
16 | companion object {
17 | const val NOTIFICATION = 0
18 | const val NEWS_TITLE = 2
19 | }
20 |
21 | class Notification(title: String, url: String) : NewsModel(NOTIFICATION, title, url)
22 |
23 | class HeadlineNews(
24 | viewType: Int,
25 | title: String,
26 | val excerpt: String,
27 | val image: String,
28 | url: String,
29 | val pinToTop: Boolean,
30 | val categories: List
31 | ) : NewsModel(viewType, title, url)
32 | }
33 |
34 | /**
35 | * View Holder
36 | */
37 | sealed class NewsViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
38 |
39 | abstract fun bind(
40 | item: T,
41 | position: Int,
42 | onNewsItemClick: ((NewsModel) -> Unit)? = null
43 | )
44 |
45 | class Notification(private val binding: ViewHolderNotificationBinding) :
46 | NewsViewHolder(binding.root) {
47 | override fun bind(
48 | item: T,
49 | position: Int,
50 | onNewsItemClick: ((NewsModel) -> Unit)?
51 | ) {
52 | itemView.setOnClickListener { onNewsItemClick?.invoke(item) }
53 | binding.title.text = item.title
54 | }
55 | }
56 |
57 | class NewsTitle(private val binding: ViewHolderNewsTitleBinding) :
58 | NewsViewHolder(binding.root) {
59 | override fun bind(
60 | item: T,
61 | position: Int,
62 | onNewsItemClick: ((NewsModel) -> Unit)?
63 | ) {
64 | itemView.setOnClickListener { onNewsItemClick?.invoke(item) }
65 |
66 | with(binding) {
67 | val color = if (position % 2 == 0) R.color.white else R.color.eggshell_white_2
68 | binding.root.setBackgroundResource(color)
69 | title.text = item.title
70 | subtitle.text = (item as NewsModel.HeadlineNews).excerpt
71 | }
72 | }
73 | }
74 | }
75 |
76 | fun getNewsViewHolder(parent: ViewGroup, viewType: Int): NewsViewHolder {
77 | val inflater = LayoutInflater.from(parent.context)
78 |
79 | return when (viewType) {
80 | NewsModel.NOTIFICATION -> {
81 | val binding = ViewHolderNotificationBinding.inflate(inflater, parent, false)
82 | NewsViewHolder.Notification(binding)
83 | }
84 |
85 | NewsModel.NEWS_TITLE -> {
86 | val binding = ViewHolderNewsTitleBinding.inflate(inflater, parent, false)
87 | NewsViewHolder.NewsTitle(binding)
88 | }
89 |
90 | else -> throw IllegalArgumentException("Unknown view type!")
91 | }
92 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/nthlink/android/client/utils/FragmentX.kt:
--------------------------------------------------------------------------------
1 | package com.nthlink.android.client.utils
2 |
3 | import android.app.ProgressDialog
4 | import android.content.Intent
5 | import android.os.Build
6 | import android.os.VibrationAttributes
7 | import android.os.VibrationEffect
8 | import android.os.Vibrator
9 | import androidx.annotation.ColorRes
10 | import androidx.annotation.StringRes
11 | import androidx.appcompat.app.AlertDialog
12 | import androidx.core.content.ContextCompat
13 | import androidx.fragment.app.Fragment
14 | import com.google.android.material.dialog.MaterialAlertDialogBuilder
15 | import com.nthlink.android.client.App
16 | import com.nthlink.android.client.R
17 | import com.nthlink.android.client.storage.sql.AppDatabase
18 | import com.nthlink.android.client.ui.MainActivity
19 | import com.nthlink.android.core.utils.NO_RESOURCE
20 |
21 | fun Fragment.showProgressDialog(): ProgressDialog = ProgressDialog.show(
22 | requireContext(),
23 | null,
24 | getString(R.string.word_loading),
25 | true,
26 | false
27 | )
28 |
29 | fun Fragment.showMessageDialog(
30 | @StringRes messageId: Int,
31 | @StringRes positiveText: Int = R.string.ok,
32 | onPositive: (() -> Unit)? = null,
33 | @StringRes negativeText: Int = R.string.cancel,
34 | onNegative: (() -> Unit)? = null
35 | ) {
36 | showMessageDialog(
37 | requireContext(),
38 | messageId,
39 | positiveText,
40 | onPositive,
41 | negativeText,
42 | onNegative
43 | )
44 | }
45 |
46 | fun Fragment.vibrate() {
47 | val vibrator = requireContext().getSystemService(Vibrator::class.java)
48 |
49 | if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
50 | val effect = VibrationEffect.createPredefined(VibrationEffect.EFFECT_CLICK)
51 | val attributes = VibrationAttributes.createForUsage(VibrationAttributes.USAGE_TOUCH)
52 | vibrator.vibrate(effect, attributes)
53 | } else {
54 | val effect = VibrationEffect.createOneShot(10, VibrationEffect.DEFAULT_AMPLITUDE)
55 | vibrator.vibrate(effect)
56 | }
57 | }
58 |
59 | fun Fragment.getDb(): AppDatabase = (requireActivity().application as App).db
60 |
61 | fun Fragment.getMainActivity() = (requireActivity() as MainActivity)
62 |
63 | fun Fragment.getRoot() = getMainActivity().root
64 |
65 | fun Fragment.getColor(@ColorRes resId: Int) = ContextCompat.getColor(requireContext(), resId)
66 |
67 | fun Fragment.showMaterialAlertDialog(
68 | overrideThemeResId: Int = NO_RESOURCE,
69 | setBuilder: MaterialAlertDialogBuilder.() -> Unit
70 | ): AlertDialog = showMaterialAlertDialog(requireContext(), overrideThemeResId, setBuilder)
71 |
72 | fun Fragment.copyToClipboard(label: String, text: String) {
73 | copyToClipboard(requireContext(), label, text)
74 | }
75 |
76 | fun Fragment.openWebPage(url: String) {
77 | val intent = getLoadWebUrlIntent(url)
78 | if (intent.resolveActivity(requireContext().packageManager) != null) {
79 | startActivity(intent)
80 | }
81 | }
82 |
83 | fun Fragment.shareText(text: String, type: String = SHARE_TYPE_TEXT) {
84 | val intent = getSendTextIntent(text, type)
85 | val shareIntent = Intent.createChooser(intent, null)
86 | startActivity(shareIntent)
87 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/nthlink/android/client/storage/sql/NewsAnalyzer.kt:
--------------------------------------------------------------------------------
1 | package com.nthlink.android.client.storage.sql
2 |
3 | import com.nthlink.android.client.ui.connection.NewsModel
4 | import com.nthlink.android.core.model.Config
5 | import kotlinx.coroutines.CoroutineScope
6 | import kotlinx.coroutines.Dispatchers.IO
7 | import kotlinx.coroutines.launch
8 | import kotlinx.coroutines.withContext
9 | import java.util.concurrent.TimeUnit
10 |
11 | class NewsAnalyzer(private val dao: ClickedNewsDao, private val scope: CoroutineScope) {
12 | private val oneMonthInMillis: Long = TimeUnit.DAYS.toMillis(30)
13 |
14 | private var notifications: List = emptyList()
15 | private var newsTitles: List = emptyList()
16 |
17 | fun addClickedNews(categories: List) {
18 | if (categories.isEmpty()) return
19 |
20 | scope.launch(IO) {
21 | val timestamp = System.currentTimeMillis()
22 | val records = categories.map { category ->
23 | ClickedNews(category = category, timestamp = timestamp)
24 | }
25 |
26 | dao.insertAll(records)
27 | }
28 | }
29 |
30 | suspend fun loadNews(config: Config) {
31 | // notifications
32 | notifications = config.notifications.map {
33 | NewsModel.Notification(it.title, it.url)
34 | }
35 |
36 | // sort news
37 | val recommendedNewsList = sortByUserPreference(config.headlineNews)
38 |
39 | // news
40 | newsTitles = recommendedNewsList.map {
41 | NewsModel.HeadlineNews(
42 | NewsModel.NEWS_TITLE,
43 | it.title,
44 | it.excerpt,
45 | it.image,
46 | it.url,
47 | it.pinToTop,
48 | it.categories
49 | )
50 | }
51 | }
52 |
53 | fun getPinnedAndRecommendedNews(): List {
54 | return ArrayList().apply {
55 | addAll(notifications)
56 | addAll(newsTitles)
57 | }
58 | }
59 |
60 | private suspend fun sortByUserPreference(newsList: List): List {
61 | return withContext(IO) {
62 | // get user's preference
63 | val categoryCounts = dao.getCategoryCountsIn(periodTimestamp = oneMonthInMillis)
64 | if (categoryCounts.isEmpty()) return@withContext newsList
65 | val userPreference = categoryCounts.associate { it.category to it.count }
66 |
67 | // score the news
68 | val scoredNewsMap = mutableMapOf()
69 | for (news in newsList) {
70 | var totalScore = 0
71 | for (category in news.categories) {
72 | val score = userPreference[category] ?: 0
73 | totalScore += score
74 | }
75 | scoredNewsMap[news] = totalScore
76 | }
77 |
78 | scoredNewsMap.toList().sortedByDescending { it.second }.map { it.first }
79 | }
80 | }
81 |
82 | fun removeExpiredClickedNews() {
83 | scope.launch(IO) {
84 | dao.deleteIn(periodTimestamp = oneMonthInMillis)
85 | }
86 | }
87 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/nthlink/android/client/ui/follow/FollowUsFragment.kt:
--------------------------------------------------------------------------------
1 | package com.nthlink.android.client.ui.follow
2 |
3 | import android.os.Bundle
4 | import android.view.LayoutInflater
5 | import android.view.View
6 | import android.view.ViewGroup
7 | import com.nthlink.android.client.R
8 | import com.nthlink.android.client.databinding.FragmentFollowUsBinding
9 | import com.nthlink.android.client.ui.common.BindingFragment
10 | import com.nthlink.android.client.utils.copyToClipboard
11 | import com.nthlink.android.client.utils.getLoadWebUrlIntent
12 | import com.nthlink.android.client.utils.openWebPage
13 | import com.nthlink.android.client.utils.showMaterialAlertDialog
14 |
15 | class FollowUsFragment : BindingFragment() {
16 | override fun bindView(
17 | inflater: LayoutInflater,
18 | container: ViewGroup?
19 | ): FragmentFollowUsBinding {
20 | return FragmentFollowUsBinding.inflate(inflater, container, false)
21 | }
22 |
23 | override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
24 | with(binding) {
25 | fbEnVisit.setOnClickListener {
26 | openWebPage("https://www.facebook.com/profile.php?id=61560873763629")
27 | }
28 | fbZhVisit.setOnClickListener {
29 | openWebPage("https://www.facebook.com/CNnthLink")
30 | }
31 | fbFaVisit.setOnClickListener {
32 | openWebPage("https://www.facebook.com/NthLinkIR/")
33 | }
34 | fbRuVisit.setOnClickListener {
35 | openWebPage("https://www.facebook.com/NthLinkRU/")
36 | }
37 | fbMyVisit.setOnClickListener {
38 | openWebPage("https://www.facebook.com/NthLinkMM/")
39 | }
40 | fbEsVisit.setOnClickListener {
41 | openWebPage("https://www.facebook.com/NthlinkES/")
42 | }
43 |
44 | igEnVisit.setOnClickListener {
45 | openWebPage("https://www.instagram.com/nthlink_vpn/")
46 | }
47 | igZhVisit.setOnClickListener {
48 | openWebPage("https://www.instagram.com/cn_nthlink/")
49 | }
50 | igFaVisit.setOnClickListener {
51 | openWebPage("https://www.instagram.com/ir_nthlink/")
52 | }
53 | igRuVisit.setOnClickListener {
54 | openWebPage("https://www.instagram.com/ru_nthlink/")
55 | }
56 | igMyVisit.setOnClickListener {
57 | openWebPage("https://www.instagram.com/mm_nthlink/")
58 | }
59 | igEsVisit.setOnClickListener {
60 | openWebPage("https://www.instagram.com/es_nthlink/")
61 | }
62 |
63 | ytVisit.setOnClickListener {
64 | startActivity(getLoadWebUrlIntent("https://www.youtube.com/@nthLinkApp"))
65 | }
66 |
67 | tgId.setOnClickListener {
68 | copyToClipboard("telegram id", "@nthLinkVPN")
69 | showMaterialAlertDialog {
70 | setTitle(R.string.copied)
71 | setMessage(R.string.copied_telegram_id)
72 | setPositiveButton(R.string.ok) { dialog, _ -> dialog.dismiss() }
73 | }
74 | }
75 | }
76 | }
77 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/nthlink/android/client/utils/Utils.kt:
--------------------------------------------------------------------------------
1 | package com.nthlink.android.client.utils
2 |
3 | import android.app.Activity
4 | import android.content.ClipData
5 | import android.content.ClipboardManager
6 | import android.content.Context
7 | import android.content.Intent
8 | import android.content.Intent.ACTION_SEND
9 | import android.content.Intent.ACTION_VIEW
10 | import android.content.Intent.EXTRA_TEXT
11 | import android.net.Uri
12 | import android.os.Build
13 | import android.webkit.CookieManager
14 | import android.webkit.ValueCallback
15 | import androidx.annotation.StringRes
16 | import androidx.appcompat.app.AlertDialog
17 | import com.google.android.material.dialog.MaterialAlertDialogBuilder
18 | import com.google.android.play.core.ktx.launchReview
19 | import com.google.android.play.core.ktx.requestReview
20 | import com.google.android.play.core.review.ReviewManagerFactory
21 | import com.nthlink.android.client.R
22 | import com.nthlink.android.core.utils.NO_RESOURCE
23 |
24 | const val SHARE_TYPE_TEXT = "text/plain"
25 |
26 | fun removeAllCookies(callback: ValueCallback? = null) {
27 | CookieManager.getInstance().removeAllCookies(callback)
28 | }
29 |
30 | fun installFromGooglePlay(context: Context): Boolean {
31 | val installer = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
32 | context.packageManager.getInstallSourceInfo(context.packageName).installingPackageName
33 | } else {
34 | context.packageManager.getInstallerPackageName(context.packageName)
35 | }
36 |
37 | return installer != null && "com.android.vending" == installer
38 | }
39 |
40 | suspend fun requireRatingApp(activity: Activity) {
41 | ReviewManagerFactory.create(activity).run {
42 | val reviewInfo = requestReview()
43 | launchReview(activity, reviewInfo)
44 | }
45 | }
46 |
47 | fun showMessageDialog(
48 | context: Context,
49 | @StringRes messageId: Int,
50 | @StringRes positiveText: Int = R.string.ok,
51 | onPositive: (() -> Unit)? = null,
52 | @StringRes negativeText: Int = R.string.cancel,
53 | onNegative: (() -> Unit)? = null
54 | ) {
55 | showMaterialAlertDialog(context) {
56 | setCancelable(false)
57 | setMessage(messageId)
58 | setPositiveButton(positiveText) { dialog, _ ->
59 | onPositive?.invoke()
60 | dialog.dismiss()
61 | }
62 |
63 | onNegative?.let {
64 | setNegativeButton(negativeText) { dialog, _ ->
65 | it.invoke()
66 | dialog.dismiss()
67 | }
68 | }
69 | }
70 | }
71 |
72 | fun getLoadWebUrlIntent(url: String) = Intent(ACTION_VIEW, Uri.parse(url))
73 |
74 | fun showMaterialAlertDialog(
75 | context: Context,
76 | overrideThemeResId: Int = NO_RESOURCE,
77 | setBuilder: MaterialAlertDialogBuilder.() -> Unit
78 | ): AlertDialog {
79 | val builder = MaterialAlertDialogBuilder(context, overrideThemeResId)
80 | builder.setBuilder()
81 | return builder.show()
82 | }
83 |
84 | fun copyToClipboard(context: Context, label: String, text: String) {
85 | val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
86 | val clip = ClipData.newPlainText(label, text)
87 | clipboard.setPrimaryClip(clip)
88 | }
89 |
90 | fun getSendTextIntent(text: String, type: String = SHARE_TYPE_TEXT) = Intent().apply {
91 | action = ACTION_SEND
92 | putExtra(EXTRA_TEXT, text)
93 | this.type = type
94 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/nthlink/android/client/ui/web/WebFragment.kt:
--------------------------------------------------------------------------------
1 | package com.nthlink.android.client.ui.web
2 |
3 | import android.os.Bundle
4 | import android.view.LayoutInflater
5 | import android.view.Menu
6 | import android.view.MenuInflater
7 | import android.view.MenuItem
8 | import android.view.View
9 | import android.view.ViewGroup
10 | import android.webkit.WebView
11 | import android.webkit.WebViewClient
12 | import android.widget.Toast
13 | import androidx.core.view.MenuProvider
14 | import androidx.core.view.isVisible
15 | import androidx.navigation.fragment.navArgs
16 | import com.nthlink.android.client.R
17 | import com.nthlink.android.client.databinding.FragmentWebBinding
18 | import com.nthlink.android.client.ui.common.BindingFragment
19 | import com.nthlink.android.client.utils.copyToClipboard
20 | import com.nthlink.android.client.utils.openWebPage
21 | import com.nthlink.android.client.utils.removeAllCookies
22 | import com.nthlink.android.client.utils.shareText
23 |
24 | class WebFragment : BindingFragment(), MenuProvider,
25 | CustomWebChromeClient.Callback {
26 |
27 | private val args: WebFragmentArgs by navArgs()
28 |
29 | override fun bindView(inflater: LayoutInflater, container: ViewGroup?): FragmentWebBinding {
30 | return FragmentWebBinding.inflate(inflater, container, false)
31 | }
32 |
33 | override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
34 | requireActivity().addMenuProvider(this, viewLifecycleOwner)
35 |
36 | with(binding.webView) {
37 | settings.userAgentString = CUSTOM_USER_AGENT
38 |
39 | webViewClient = WebViewClient()
40 | webChromeClient = CustomWebChromeClient(this@WebFragment)
41 | }
42 |
43 | removeAllCookies {
44 | if (isBindingNotNull()) binding.webView.loadUrl(args.url, customExtraHeaders)
45 | }
46 | }
47 |
48 | override fun onProgressChanged(view: WebView, newProgress: Int) {
49 | binding.progressBar.progress = newProgress
50 | binding.progressNum.text = getString(R.string.percentage, newProgress)
51 | }
52 |
53 | override fun onStartLoading(view: WebView) {
54 | binding.progressBar.isVisible = true
55 | binding.progressNum.isVisible = true
56 | }
57 |
58 | override fun onLoading(view: WebView) {}
59 |
60 | override fun onFinishLoading(view: WebView) {
61 | binding.progressBar.isVisible = false
62 | binding.progressNum.isVisible = false
63 | }
64 |
65 | override fun onCreateMenu(menu: Menu, inflater: MenuInflater) {
66 | inflater.inflate(R.menu.fragment_web, menu)
67 | }
68 |
69 | override fun onMenuItemSelected(item: MenuItem): Boolean {
70 | return when (item.itemId) {
71 | R.id.option_web_item_1 -> {
72 | copyToClipboard(getString(R.string.app_name), args.url)
73 | Toast.makeText(requireContext(), R.string.word_copied_link, Toast.LENGTH_LONG)
74 | .show()
75 | true
76 | }
77 |
78 | R.id.option_web_item_2 -> {
79 | openWebPage(args.url)
80 | true
81 | }
82 |
83 | R.id.option_web_item_3 -> {
84 | shareText(args.url)
85 | true
86 | }
87 |
88 | else -> false
89 | }
90 | }
91 |
92 | override fun onDestroyView() {
93 | binding.webView.webChromeClient = null
94 | binding.webView.destroy()
95 | super.onDestroyView()
96 | }
97 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/nthlink/android/client/ui/diagnostic/DiagnosticFragment.kt:
--------------------------------------------------------------------------------
1 | package com.nthlink.android.client.ui.diagnostic
2 |
3 | import android.app.AlertDialog
4 | import android.os.Bundle
5 | import android.view.LayoutInflater
6 | import android.view.View
7 | import android.view.ViewGroup
8 | import android.widget.Toast
9 | import androidx.core.view.isVisible
10 | import androidx.lifecycle.Lifecycle
11 | import androidx.lifecycle.lifecycleScope
12 | import androidx.lifecycle.repeatOnLifecycle
13 | import com.nthlink.android.client.R
14 | import com.nthlink.android.client.databinding.FragmentDiagnosticBinding
15 | import com.nthlink.android.client.ui.common.BindingFragment
16 | import com.nthlink.android.client.utils.copyToClipboard
17 | import com.nthlink.android.client.utils.getRoot
18 | import com.nthlink.android.client.utils.showMessageDialog
19 | import com.nthlink.android.client.utils.showProgressDialog
20 | import com.nthlink.android.core.Root
21 | import com.nthlink.android.core.Root.DiagnosticResult
22 | import com.nthlink.android.core.Root.Status.CONNECTED
23 | import kotlinx.coroutines.launch
24 |
25 | class DiagnosticFragment : BindingFragment() {
26 | private lateinit var root: Root
27 | private var loadingDialog: AlertDialog? = null
28 |
29 | override fun bindView(
30 | inflater: LayoutInflater,
31 | container: ViewGroup?
32 | ): FragmentDiagnosticBinding {
33 | return FragmentDiagnosticBinding.inflate(inflater, container, false)
34 | }
35 |
36 | override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
37 | root = getRoot()
38 |
39 | binding.copyReportId.isVisible = false
40 | binding.start.setOnClickListener {
41 | binding.start.isEnabled = false
42 |
43 | // show alert dialog before starting diagnostics if VPN is connected
44 | if (root.status == CONNECTED) {
45 | showMessageDialog(
46 | messageId = R.string.diagnostic_disconnect_alert,
47 | onPositive = { startDiagnostics() },
48 | onNegative = {}
49 | )
50 | } else startDiagnostics()
51 | }
52 |
53 | lifecycleScope.launch {
54 | viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
55 | launch {
56 | root.diagnosticResultFlow.collect { result ->
57 | loadingDialog?.dismiss()
58 | loadingDialog = null
59 |
60 | when (result) {
61 | is DiagnosticResult.ErrNoInternet -> {
62 | showMessageDialog(messageId = R.string.error_no_internet)
63 | }
64 |
65 | is DiagnosticResult.Ok -> {
66 | setupResult(result)
67 | }
68 | }
69 | }
70 | }
71 | }
72 | }
73 | }
74 |
75 | private fun startDiagnostics() {
76 | loadingDialog = showProgressDialog()
77 | root.startDiagnostics()
78 | }
79 |
80 | private fun setupResult(result: DiagnosticResult.Ok) {
81 | val message = """
82 | ${getString(R.string.feedback_submit_success_message)}
83 |
84 | Report ID: ${result.reportId}
85 | """.trimIndent()
86 |
87 | binding.result.text = message
88 | binding.copyReportId.isVisible = true
89 | binding.copyReportId.setOnClickListener {
90 | copyToClipboard("Report ID", result.reportId)
91 | Toast.makeText(requireContext(), R.string.copied, Toast.LENGTH_LONG).show()
92 | }
93 | }
94 | }
--------------------------------------------------------------------------------
/app/src/main/res/values-zh-rCN/strings.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 | 同意并继续
4 | 连接
5 | 断开连接
6 | 初始化中…
7 | 正在连接…
8 | 正在断开…
9 | 提交反馈
10 | 问题类别
11 | 描述
12 | 您的描述、电子邮件地址(如果提供)和其他信息将发送给 nthLink 团队。有关我们的数据收集政策,请参阅 nthLink 网站的隐私政策。
13 | 传送
14 | 无法连接到 nthlink 服务器
15 | 感谢您帮助我们改进产品!欢迎您随时提供反馈。
16 | 关于我们
17 | 版本 %s
18 | 我们是一群经验丰富的软件和信息安全工程师,于 2016 年创立了 nthLink,为需要获取受限信息并表达观点的人权律师提供支持。 从那时起,我们就向更广泛的公众提供了这项服务。 我们的开发团队是网络安全技术和服务可靠性方面的专家。\n\n我们的服务是免费的,并且由于我们的赞助商和合作伙伴,我们将继续免费。 他们是:\n• Open Technology Fund\n• Google Jigsaw\n• Cure53\n• Include Security\n• Plaintext Design\n\n我们的开发团队在审查规避技术的复杂性和服务的可靠性方面均表现出色。 凭借在这一专业领域的多年经验,我们为目标地区的用户提供简单、安全、可靠的访问方式,以获取未经审查的信息。
19 | 连接
20 | @string/feedback_page_title
21 | @string/about_page_title
22 | 帮助
23 | 评分
24 | 复制连结
25 | 在浏览器中开启
26 | 分享链接
27 | 首页
28 | 正在加载
29 | 连结已复制
30 |
31 | - 一般反馈
32 | - 无法连线
33 | - 速度太慢
34 | - 建议
35 | - 其他
36 |
37 | https://www.nthlink.com/zh/#faq
38 | https://nthlink.com/zh/policies/
39 | 下载
40 | 已复制
41 | 请在 Telegram 上粘贴 ID。
42 | 更新
43 | 您的应用是最新的版本
44 | 更新已取消
45 | nthlink 有新版本。您想下载吗?
46 | 出了点问题。请再试一次。
47 | 关注我们
48 | 访问
49 | 此操作将打开 VPN 设置。您要继续吗?\n\n点击"确定"后,点击 nthlink 旁边的齿轮图标以启用"始终开启 VPN"和"阻止无 VPN 连接"。\n\n请确保至少连接 nthlink 一次,以确保系统正确显示 VPN 应用程序。
50 | 诊断
51 | 开始诊断
52 | 在 nthLink,我们有时会请求您通过提供诊断数据来帮助改进我们的服务。\n\n点击按钮后,诊断过程将开始,收集信息并传输至我们的服务器。请放心,所有收集的信息都是完全匿名的。
53 | 开始诊断将断开当前连接。\n\n您确定要继续吗?
54 | 复制报告 ID
55 | 隐私政策
56 | 下载更新
57 | 正在下载更新…
58 | 下载完成!\n要更新 nthLink,请打开“文件”应用,找到最新的 nthLink APK,然后点击它进行安装。
59 | 打开“文件”应用
60 |
--------------------------------------------------------------------------------
/app/src/main/res/values-zh-rTW/strings.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 | 同意並繼續
4 | 連線
5 | 中斷連線
6 | 初始化…
7 | 正在連線…
8 | 正在中斷…
9 | 問題回報
10 | 問題類別
11 | 描述
12 | 您的描述、電子郵件地址(如果提供)和其他信息將發送給 nthLink 團隊。有關我們的數據收集政策,請參閱 nthLink 網站的隱私政策。
13 | 傳送
14 | 無法連線至 nthlink 服務器
15 | 感謝你協助我們改善服務!歡迎隨時提供意見給我們。
16 | 關於我們
17 | 版本 %s
18 | 我們是一群經驗豐富的軟體和資訊安全工程師,於 2016 年創立了 nthLink,為需要獲取受限資訊並表達觀點的人權律師提供支援。 從那時起,我們就向更廣泛的公眾提供了這項服務。 我們的開發團隊是網路安全技術和服務可靠性方面的專家。\n\n我們的服務是免費的,並且由於我們的贊助商和合作夥伴,我們將繼續免費。 他們是:\n• Open Technology Fund\n• Google Jigsaw\n• Cure53\n• Include Security\n• Plaintext Design\n\n我們的開發團隊在審查規避技術的複雜性和服務的可靠性方面均表現出色。 憑藉在這一專業領域的多年經驗,我們為目標地區的用戶提供簡單、安全、可靠的存取方式,以獲取未經審查的資訊。
19 | 連線
20 | @string/feedback_page_title
21 | @string/about_page_title
22 | 幫助
23 | 評分
24 | 複製連結
25 | 在瀏覽器中開啟
26 | 分享連結
27 | 首頁
28 | 讀取中
29 | 連結已複製
30 |
31 | - 一般問題
32 | - 無法連線
33 | - 速度太慢
34 | - 建議
35 | - 其他
36 |
37 | https://www.nthlink.com/zh/#faq
38 | https://nthlink.com/zh/policies/
39 | 下載
40 | 已複製
41 | 請在 Telegram 上貼上 ID。
42 | 更新
43 | 您的應用是最新的版本
44 | 更新已取消
45 | nthlink 有新版本。你想下載嗎?
46 | 出了點問題。請再試一次。
47 | 追蹤我們
48 | 前往
49 | 此操作將開啟 VPN 設定。您要繼續嗎?\n\n點擊「確定」後,點擊 nthlink 旁邊的齒輪圖示以啟用「永遠開啟 VPN」和「封鎖無 VPN 連線」。\n\n請確保至少連接 nthlink 一次,以確保系統正確顯示 VPN 應用程式。
50 | 診斷
51 | 開始診斷
52 | 在 nthLink,我們有時會請您協助提供診斷資料,以幫助我們改善服務。\n\n點擊按鈕後,診斷程序將開始,收集資訊並傳送至我們的伺服器。請放心,所有收集的資訊皆為完全匿名。
53 | 開始診斷將中斷目前的連線。\n\n您確定要繼續嗎?
54 | 複製報告 ID
55 | 隱私權政策
56 | 下載更新
57 | 正在下載更新…
58 | 下載完成!\n要更新 nthLink,請打開「檔案」應用程式,找到最新的 nthLink APK,然後點按它以安裝。
59 | 開啟檔案應用程式
60 |
--------------------------------------------------------------------------------
/core/src/main/java/com/nthlink/android/core/Core.kt:
--------------------------------------------------------------------------------
1 | package com.nthlink.android.core
2 |
3 | import com.nthlink.android.core.utils.EMPTY
4 |
5 | internal object Core {
6 | fun encrypt(text: String): String {
7 | // TODO Not yet implemented
8 | return text
9 | }
10 |
11 | fun decrypt(text: String): String {
12 | // TODO Not yet implemented
13 | return text
14 | }
15 |
16 | fun getConfig(): String {
17 | // TODO Not yet implemented
18 | return """
19 | {
20 | "servers": [
21 | {
22 | "protocol": "",
23 | "host": "www.abc.com",
24 | "port": "443",
25 | "password": "password",
26 | "sni": "www.abc.com",
27 | "ws": true,
28 | "ws_path": "/abc",
29 | "ws_host": "www.abc.com"
30 | }
31 | ],
32 | "redirectUrl": "https://www.persagg.com/zh/?utm_medium=proxy\u0026utm_source=nthlink",
33 | "headlineNews": [
34 | {
35 | "title": "致读者:廿九载灯火暂熄,我们与时代的故事未完待续",
36 | "excerpt": "",
37 | "image": "",
38 | "url": "https://rfa.org/mandarin/zhengzhi/2025/10/31/rfa-mandarin-closure-history-us-china/?utm_medium=proxy\u0026utm_campaign=persagg\u0026utm_source=nthlink\u0026utm_content=image",
39 | "pinToTop": false
40 | },
41 | {
42 | "title": "美国政府看来即将面临七年来首次关门",
43 | "excerpt": "",
44 | "image": "",
45 | "url": "https://www.voachinese.com/a/us-government-appears-headed-for-first-shutdown-in-7-years-20250930/8070174.html?utm_medium=proxy\u0026utm_campaign=persagg\u0026utm_source=nthlink\u0026utm_content=image",
46 | "pinToTop": false
47 | },
48 | {
49 | "title": "特朗普和海格塞斯罕见召集美军将领,承诺2026年对军队投入超过1万亿美元",
50 | "excerpt": "",
51 | "image": "",
52 | "url": "https://www.voachinese.com/a/trump-hegseth-address-rare-gathering-of-us-army-commanders-commit-to-over-1-trillion-investment-in-the-military-in-2026-20250930/8070165.html?utm_medium=proxy\u0026utm_campaign=persagg\u0026utm_source=nthlink\u0026utm_content=image",
53 | "pinToTop": false
54 | }
55 | ],
56 | "notifications": [
57 | {
58 | "title": "Download nthLink",
59 | "url": "https://www.downloadnth.com/download.html"
60 | }
61 | ],
62 | "data": "",
63 | "static": false,
64 | "use_custom_config": false,
65 | "custom_config": "",
66 | "current_versions": [
67 | {
68 | "app_name": "nthlink",
69 | "platforms": [
70 | {
71 | "os": "android",
72 | "version": "6.8.0",
73 | "url": "https://www.downloadnth.com/nthlink-6.8.0-release.apk"
74 | },
75 | {
76 | "os": "windows32",
77 | "version": "6.8.0.1",
78 | "url": "https://www.downloadnth.com/nthLink_Installer_x86_6.8.0.1.exe"
79 | },
80 | {
81 | "os": "windows64",
82 | "version": "6.8.0.1",
83 | "url": "https://www.downloadnth.com/nthLink_Installer_x64_6.8.0.1.exe"
84 | },
85 | {
86 | "os": "ios",
87 | "version": "6.7.9",
88 | "url": ""
89 | },
90 | {
91 | "os": "macos",
92 | "version": "6.7.9",
93 | "url": ""
94 | }
95 | ]
96 | }
97 | ]
98 | }
99 | """.trimIndent()
100 | }
101 |
102 | fun feedback(
103 | feedbackType: String,
104 | description: String = EMPTY,
105 | appVersion: String = EMPTY,
106 | email: String = EMPTY
107 | ) {
108 | // TODO Not yet implemented
109 | }
110 |
111 | fun startDiagnostics(): String {
112 | // TODO Not yet implemented
113 | return "Report ID"
114 | }
115 | }
--------------------------------------------------------------------------------
/app/src/main/res/values-ko/strings.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 | 수락 및 계속
4 | 연결
5 | 연결 해제
6 | 초기화 중…
7 | 연결 중…
8 | 연결 해제 중…
9 | 피드백
10 | 문제 카테고리
11 | 설명
12 | 귀하의 설명, 이메일 주소(제공된 경우) 및 추가 정보가 nthLink 팀으로 전송됩니다. 당사의 데이터 수집 정책은 nthLink 웹사이트의 개인정보 보호정책을 참조하십시오.
13 | 보내기
14 | nthlink 서버에 연결할 수 없습니다.
15 | 개선하는 데 도움을 주셔서 감사합니다! 우리는 당신의 의견을 듣는 것을 좋아합니다.
16 | 정보
17 | 버전 %s
18 | 우리는 제한된 정보를 얻고 자신의 관점을 표현해야 하는 인권 변호사를 지원하기 위해 2016년 nthLink를 시작한 숙련된 소프트웨어 및 정보 보안 엔지니어 그룹입니다. 그 이후로 우리는 더 많은 대중에게 서비스를 제공했습니다. 우리 개발팀은 네트워크 보안 기술과 서비스 안정성 분야의 전문가입니다.\n\n우리의 서비스는 무료이며 후원자와 파트너 덕분에 계속 무료로 유지될 것입니다. 그들은:\n• Open Technology Fund\n• Google Jigsaw\n• Cure53\n• Include Security\n• Plaintext Design\n\n우리 개발 팀은 검열 우회 기술의 정교함과 서비스의 신뢰성 모두에서 탁월합니다. 이 전문 분야에서 다년간의 경험을 바탕으로 대상 지역의 사용자에게 검열된 정보에 간단하고 안전하며 신뢰할 수 있는 액세스를 제공합니다.
19 | 연결
20 | @string/feedback_page_title
21 | @string/about_page_title
22 | 도움말
23 | 앱 평가
24 | 링크 복사
25 | 브라우저에서 열기
26 | 링크 공유
27 | 랜딩 페이지
28 | 로딩 중
29 | 대응 링크
30 |
31 | - 일반 피드백
32 | - 연결할 수 없습니다
33 | - 연결 속도가 느립니다
34 | - 제안
35 | - 기타
36 |
37 | https://www.nthlink.com/#faq
38 | https://nthlink.com/policies/
39 | 다운로드
40 | 복사됨
41 | Telegram에 ID를 붙여넣어주세요.
42 | 업데이트
43 | 앱이 최신 상태입니다
44 | 업데이트가 취소되었습니다
45 | nthlink의 새 버전이 있습니다. 다운로드하시겠습니까?
46 | 문제가 발생했습니다. 다시 시도해 주세요.
47 | 팔로우 하세요
48 | 방문
49 | 이 작업은 VPN 설정을 엽니다. 계속하시겠습니까?\n\n\"확인\"을 클릭한 후, nthlink 옆에 있는 기어 아이콘을 탭하여 \"항상 켜진 VPN\" 및 \"VPN 없이 연결 차단\"을 활성화하세요.\n\n시스템이 VPN 앱을 올바르게 표시하도록 하려면 최소한 한 번은 nthlink에 연결해 주세요.
50 | 진단
51 | 진단 시작
52 | nthLink에서는 가끔 서비스 향상을 위해 진단 데이터를 제공해 주시기를 요청드리기도 합니다.\n\n버튼을 클릭하면 진단 프로세스가 시작되어 정보를 수집하고 서버로 전송합니다. 수집된 모든 정보는 완전히 익명으로 처리되니 안심하셔도 됩니다.
53 | 진단을 시작하면 현재 연결이 끊어집니다.\n\n계속하시겠습니까?
54 | 보고서 ID 복사
55 | 개인정보 처리방침
56 | 업데이트 다운로드
57 | 업데이트 다운로드 중…
58 | 다운로드 완료!\nnthLink를 업데이트하려면 파일 앱을 열고 최신 nthLink APK를 찾아 탭하여 설치하세요.
59 | 파일 앱 열기
60 |
--------------------------------------------------------------------------------
/app/src/main/res/values-ja-rJP/strings.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 | 受け入れて続行
4 | 接続
5 | 切断
6 | 初期化中…
7 | 接続中…
8 | 切断中…
9 | フィードバック
10 | 問題のカテゴリ
11 | 説明
12 | 説明、電子メールアドレス(提供されている場合)、および追加情報が nthLink チームに送信されます。データ収集ポリシーについては、nthLinkWeb サイトのプライバシーポリシーを参照してください。
13 | 送信
14 | nthlink サーバーに接続できません。
15 | 改善にご協力いただきありがとうございます。ご連絡をお待ちしております。
16 | 概要
17 | バージョン%s
18 | 私たちは、経験豊富なソフトウェアおよび情報セキュリティ エンジニアのグループであり、制限された情報を入手し、自分たちの見解を表明する必要がある人権弁護士をサポートするために、2016 年に nthLink を設立しました。 それ以来、このサービスを広く一般の人が利用できるようにしてきました。 当社の開発チームは、ネットワーク セキュリティ テクノロジーとサービスの信頼性の両方の専門家です。\n\n私たちのサービスは無料であり、スポンサーとパートナーのおかげで今後も無料であり続けます。 彼らです:\n• Open Technology Fund\n• Google Jigsaw\n• Cure53\n• Include Security\n• Plaintext Design\n\n私たちの開発チームは、高度な検閲回避技術とサービスの信頼性の両方に優れています。この専門分野での長年の経験により、対象地域のユーザーに、検閲された情報への簡単で安全かつ信頼性の高いアクセスを提供します。
19 | 接続
20 | @string/feedback_page_title
21 | @string/about_page_title
22 | ヘルプ
23 | アプリを評価
24 | リンクをコピー
25 | ブラウザで開く
26 | 共有リンク
27 | ランディングページ
28 | 読み込み中
29 | 対処されたリンク
30 |
31 | - 一般的なフィードバック
32 | - 接続できません
33 | - 接続速度が遅い
34 | - 提案
35 | - 他の
36 |
37 | https://www.nthlink.com/#faq
38 | https://nthlink.com/policies/
39 | ダウンロード
40 | コピーしました
41 | TelegramにIDを貼り付けてください。
42 | 更新
43 | アプリが最新です
44 | 更新がキャンセルされました
45 | nthlink の新しいバージョンがあります。ダウンロードしますか?
46 | 問題が発生しました。もう一度お試しください。
47 | フォローする
48 | 訪問
49 | この操作によりVPN設定が開きます。続行しますか?\n\n\"OK\"をクリックした後、nthlinkの横にある歯車アイコンをタップして「常時接続VPN」と「VPNなしの接続をブロック」を有効にしてください。\n\nシステムがVPNアプリを正しく表示することを確認するために、少なくとも一度はnthlinkに接続してください。
50 | 診断
51 | 診断を開始
52 | nthLink では、サービス向上のために診断データの提供をお願いすることがあります。\n\nボタンをクリックすると診断プロセスが開始され、情報が収集され当社のサーバーに送信されます。収集された情報はすべて完全に匿名ですのでご安心ください。
53 | 診断を開始すると現在の接続が切断されます。\n\n本当に続行しますか?
54 | レポート ID をコピー
55 | プライバシーポリシー
56 | アップデートをダウンロード
57 | アップデートをダウンロード中…
58 | ダウンロードが完了しました!\nnthLink を更新するには、ファイルアプリを開き、最新の nthLink APK を見つけてタップしてインストールしてください。
59 | ファイルアプリを開く
60 |
--------------------------------------------------------------------------------
/app/src/main/res/layout/fragment_feedback.xml:
--------------------------------------------------------------------------------
1 |
2 |
8 |
9 |
13 |
14 |
18 |
19 |
24 |
25 |
26 |
27 |
32 |
33 |
37 |
38 |
43 |
44 |
45 |
46 |
52 |
53 |
58 |
59 |
65 |
66 |
67 |
68 |
75 |
76 |
85 |
86 |
95 |
96 |
--------------------------------------------------------------------------------
/app/src/main/res/values/dimens.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 | 0dp
4 |
5 | 50dp
6 |
7 | 10sp
8 | 12sp
9 | 14sp
10 | 16sp
11 | 18sp
12 | 20sp
13 | 22sp
14 | 24sp
15 | 26sp
16 | 28sp
17 | 30sp
18 | 32sp
19 | 34sp
20 | 36sp
21 | 38sp
22 | 40sp
23 | 42sp
24 | 44sp
25 | 46sp
26 | 48sp
27 | 50sp
28 | 52sp
29 | 54sp
30 | 56sp
31 | 58sp
32 | 60sp
33 | 62sp
34 | 64sp
35 | 66sp
36 | 68sp
37 | 70sp
38 | 72sp
39 |
40 | 1dp
41 | 2dp
42 | 3dp
43 | 4dp
44 | 5dp
45 | 6dp
46 | 7dp
47 | 8dp
48 | 9dp
49 | 10dp
50 | 12dp
51 | 14dp
52 | 16dp
53 | 18dp
54 | 20dp
55 | 22dp
56 | 24dp
57 | 26dp
58 | 28dp
59 | 30dp
60 | 32dp
61 | 34dp
62 | 36dp
63 | 38dp
64 | 40dp
65 | 42dp
66 | 44dp
67 | 46dp
68 | 48dp
69 | 50dp
70 | 52dp
71 | 54dp
72 | 56dp
73 | 58dp
74 | 60dp
75 | 62dp
76 | 64dp
77 | 66dp
78 | 68dp
79 | 70dp
80 | 72dp
81 | 74dp
82 | 76dp
83 | 78dp
84 | 96dp
85 | 100dp
86 | 120dp
87 | 150dp
88 | 160dp
89 | 180dp
90 | 200dp
91 | 300dp
92 | 400dp
93 | 500dp
94 | 600dp
95 |
96 |
--------------------------------------------------------------------------------
/app/src/main/res/drawable/ic_ig.xml:
--------------------------------------------------------------------------------
1 |
7 |
8 |
9 |
14 |
17 |
20 |
23 |
26 |
27 |
28 |
29 |
30 |
31 |
36 |
39 |
42 |
45 |
46 |
47 |
48 |
51 |
52 |
--------------------------------------------------------------------------------
/app/src/main/res/values-am-rET/strings.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 | ተቀበል እና ቀጥል።
4 | ተገናኝ
5 | ግንኙነት አቋርጥ
6 | በመጀመር ላይ…
7 | በማገናኘት ላይ…
8 | ግንኙነት በማቋረጥ ላይ…
9 | ግብረ መልስ
10 | የችግር ምድብ
11 | መግለጫ
12 | የእርስዎ መግለጫ፣ የኢሜይል አድራሻ (ተሰጥቶ ከሆነ) እና ተጨማሪ መረጃ ለ nthLink ቡድን ይላካል። እባኮትን ለመረጃ አሰባሰብ መመሪያችን የnthLink ድረ ገጽን የግላዊነት ፖሊሲ ይመልከቱ።
13 | ላክ
14 | ከ nthlink ሰርቨር ጋር መገናኘት አልተቻለም።
15 | እንድናሻሽል ስለረዱን እናመሰግናለን! ከእርስዎ መስማት እንወዳለን።
16 | ስለ
17 | ስሪት %s
18 | እኛ በ 2016 የተገደበ መረጃ ለማግኘት የሚያስፈልጋቸውን የሰብአዊ መብት ጠበቆችን ለመደገፍ እና አመለካከታቸውን ለመግለጽ nthLink የጀመርን የሶፍትዌር እና የመረጃ ደህንነት መሐንዲሶች ቡድን ነን። ከዚያን ጊዜ ጀምሮ አገልግሎቱን ለሰፊው ህዝብ ተደራሽ አድርገነዋል። የእኛ የእድገት ቡድን በሁለቱም የአውታረ መረብ ደህንነት ቴክኖሎጂ እና የአገልግሎት አስተማማኝነት ባለሞያዎች ናቸው።\n\nአገልግሎታችን ነፃ ነው እና ለስፖንሰሮቻችን እና አጋሮቻችን ምስጋና ይግባው ይቀጥላል። ናቸው:\n• Open Technology Fund\n• Google Jigsaw\n• Cure53\n• Include Security\n• Plaintext Design\n\nየእኛ የእድገት ቡድን በሳንሱር ችግር አፈታት ቴክኖሎጂ ውስብስብነት እና በአገልግሎቱ አስተማማኝነት የላቀ ነው። የዓመታት ልምድ ባገኘንበት በዚህ መስክ ለታላሚ አካባቢዎች ቀላል፣ ደህንነቱ የተጠበቀ እና አስተማማኝ የመረጃ መዳረሻን ለተጠቃሚዎች እናቀርባለን።
19 | ተገናኝ
20 | @string/feedback_page_title
21 | @string/about_page_title
22 | እገዛ
23 | መተግበሪያ ደረጃ ይስጡ
24 | አገናኝ ቅዳ
25 | በአሳሽ ውስጥ ክፈት
26 | አገናኝ አጋራ
27 | ማረፊያ ገጽ
28 | በመጫን ላይ
29 | የታገዘ አገናኝ
30 |
31 | - አጠቃላይ አስተያየት
32 | - መገናኘት አልተቻለም
33 | - የግንኙነት ፍጥነት ቀርፋፋ ነው።
34 | - ጥቆማዎች
35 | - ሌላ
36 |
37 | https://www.nthlink.com/#faq
38 | https://nthlink.com/policies/
39 | አውርድ
40 | Yekupotolo
41 | Baza ID kan Telegiram
42 | አዘምን
43 | አፕሊኬሽኑ ወቅታዊ ነው
44 | አዘምነቱ ተሰርዟል
45 | የ nthlink አዲስ እትም አለ። መካከል እፈልጋለህ?
46 | የተሳሳተ ነገር አጋጥሟል። እባኮትን በድጋሚ ይሞክሩት።
47 | ተከተሉን
48 | ጎብኙ
49 | ይህ እርምጃ የ VPN ቅንብሮችን ይከፍታል። መቀጠል ትፈልጋለህ?\n\n\"OK\" ከጠቆሙ በኋላ፣ nthlink አጠገብ ያለውን የማስተካከያ አዶ መታ በማድረግ \"ሁልጊዜ የሚሰራ VPN\" እና \"ያለ VPN ግንኙነቶችን አግድ\" አንቃ።\n\nሲስተሙ የ VPN መተግበሪያውን በትክክል እንዲያሳይ ለማረጋገጥ እባክዎ ቢያንስ አንዴ nthlink ጋር ይገናኙ።
50 | ዲያግኖስቲክ
51 | ዲያግኖስቲክን ጀምር
52 | በ nthLink ውስጥ፣ በአንዳንድ ጊዜ አገልግሎታችንን ለማሻሻል ዲያግኖስቲክ ውሂብ ማቅረብዎን በተለይ እንፈልጋለን።\n\nበአዝራሩ ላይ ጠቅ በማድረግ፣ ዲያግኖስቲክ ሂደቃችን ይጀምራል፣ መረጃን ያስቀምጥ እና ወደ አገልግሎታችን አገልግሎት መላክ ይደርሳል። የሚሰበሰብበት መረጃ ሙሉ በሙሉ ያልታወቀ መሆኑን እርግጠኛ ነህ።
53 | ዲያግኖስቲክን ማብረር መስመር አሁን ያለውን አገናኝ ይቈላል።\n\nቀጥል መሄድ መፈለግህ እንደሆነ አስቡ?
54 | የሪፖርት መለያ አቅርብ
55 | መረጃ ጥበቃ ፖሊሲ
56 | ዝርዝር ያውርዱ
57 | ዝርዝር እየወረደ ነው…
58 | አውርድ ተጠናቋል!\nnthLinkን ለማዘመን፣ Files መተግበሪያውን ክፈት፣ የአዲሱን nthLink APK ፋይል ፈልግ፣ እና ለመጫን እሱን ይጭኑ.
59 | የፋይሎች መተግበሪያን ክፈቱ
60 |
--------------------------------------------------------------------------------
/core/src/main/java/com/nthlink/android/core/RootVpn.kt:
--------------------------------------------------------------------------------
1 | package com.nthlink.android.core
2 |
3 | import android.content.Context
4 | import android.util.Log
5 | import androidx.lifecycle.DefaultLifecycleObserver
6 | import androidx.lifecycle.LifecycleCoroutineScope
7 | import androidx.lifecycle.LifecycleOwner
8 | import androidx.lifecycle.lifecycleScope
9 | import com.nthlink.android.core.Root.DiagnosticResult
10 | import com.nthlink.android.core.Root.Error
11 | import com.nthlink.android.core.Root.Status
12 | import com.nthlink.android.core.model.Config
13 | import com.nthlink.android.core.storage.readConfig
14 | import com.nthlink.android.core.storage.saveConfig
15 | import com.nthlink.android.core.utils.JsonParser
16 | import com.nthlink.android.core.utils.TAG
17 | import com.nthlink.android.core.utils.isOnline
18 | import kotlinx.coroutines.Dispatchers.IO
19 | import kotlinx.coroutines.flow.MutableSharedFlow
20 | import kotlinx.coroutines.flow.MutableStateFlow
21 | import kotlinx.coroutines.flow.SharedFlow
22 | import kotlinx.coroutines.flow.StateFlow
23 | import kotlinx.coroutines.flow.asSharedFlow
24 | import kotlinx.coroutines.launch
25 |
26 | internal abstract class RootVpn(private val context: Context) : Root, DefaultLifecycleObserver {
27 | private val _statusFlow = MutableStateFlow(Status.DISCONNECTED)
28 | override val statusFlow: StateFlow get() = _statusFlow
29 |
30 | private val _errorFlow = MutableSharedFlow()
31 | override val errorFlow: SharedFlow get() = _errorFlow
32 |
33 | private val _diagnosticResultFlow = MutableSharedFlow()
34 | override val diagnosticResultFlow: SharedFlow get() = _diagnosticResultFlow.asSharedFlow()
35 |
36 | private lateinit var scope: LifecycleCoroutineScope
37 |
38 | override fun onCreate(owner: LifecycleOwner) {
39 | scope = owner.lifecycleScope
40 | }
41 |
42 | override fun connect(config: String) {
43 | scope.launch(IO) {
44 | updateStatus(Status.INITIALIZING)
45 |
46 | // check internet
47 | if (!isOnline(context)) {
48 | updateStatus(Status.DISCONNECTED)
49 | emitError(Error.NO_INTERNET)
50 | return@launch
51 | }
52 |
53 | // check if there is a config from client
54 | if (config.isNotEmpty()) {
55 | runVpn(config)
56 | } else {
57 | getConfigFromDirectoryServer()
58 | }
59 | }
60 | }
61 |
62 | private suspend fun getConfigFromDirectoryServer() {
63 | try {
64 | // get config from Directory Server
65 | val config = JsonParser.toConfig(Core.getConfig())
66 |
67 | // save config
68 | saveConfig(context, config)
69 |
70 | // run VPN
71 | if (config.useCustomConfig) {
72 | if (config.customConfig.isEmpty()) {
73 | updateStatus(Status.DISCONNECTED)
74 | emitError(Error.INVALID_CONFIG)
75 | return
76 | }
77 |
78 | runVpn(config.customConfig)
79 | } else {
80 | if (config.servers.isEmpty()) {
81 | updateStatus(Status.DISCONNECTED)
82 | emitError(Error.NO_PROXY_AVAILABLE)
83 | return
84 | }
85 |
86 | runVpn(config.servers)
87 | }
88 | } catch (e: Throwable) {
89 | Log.e(TAG, "get config error: ", e)
90 | updateStatus(Status.DISCONNECTED)
91 | emitError(Error.GET_CONFIG_ERROR)
92 | }
93 | }
94 |
95 | abstract suspend fun runVpn(servers: List)
96 |
97 | abstract suspend fun runVpn(config: String)
98 |
99 | protected fun updateStatus(status: Status) {
100 | _statusFlow.value = status
101 | }
102 |
103 | protected suspend fun emitError(error: Error) = _errorFlow.emit(error)
104 |
105 | override suspend fun getConfig(): Config? = readConfig(context)
106 |
107 | override fun startDiagnostics() {
108 | scope.launch(IO) {
109 | // disconnect VPN
110 | if (status != Status.DISCONNECTED) disconnect()
111 |
112 | // check internet
113 | if (!isOnline(context)) {
114 | _diagnosticResultFlow.emit(DiagnosticResult.ErrNoInternet)
115 | return@launch
116 | }
117 |
118 | val reportId = Core.startDiagnostics()
119 |
120 | _diagnosticResultFlow.emit(DiagnosticResult.Ok(reportId))
121 | }
122 | }
123 | }
--------------------------------------------------------------------------------
/app/src/main/java/com/nthlink/android/client/ui/update/ApkUpdateFragment.kt:
--------------------------------------------------------------------------------
1 | package com.nthlink.android.client.ui.update
2 |
3 | import android.os.Bundle
4 | import android.view.LayoutInflater
5 | import android.view.View
6 | import android.view.ViewGroup
7 | import androidx.core.view.isVisible
8 | import androidx.lifecycle.Lifecycle
9 | import androidx.lifecycle.lifecycleScope
10 | import androidx.lifecycle.repeatOnLifecycle
11 | import androidx.navigation.fragment.navArgs
12 | import com.nthlink.android.client.R
13 | import com.nthlink.android.client.databinding.FragmentApkUpdateBinding
14 | import com.nthlink.android.client.ui.common.BindingFragment
15 | import com.nthlink.android.client.updates.ApkDownloadManager
16 | import com.nthlink.android.client.updates.DownloadState
17 | import kotlinx.coroutines.launch
18 | import org.koin.androidx.viewmodel.ext.android.viewModel
19 |
20 | class ApkUpdateFragment : BindingFragment() {
21 | private val args: ApkUpdateFragmentArgs by navArgs()
22 | private val viewModel by viewModel()
23 |
24 | private lateinit var version: String
25 | private lateinit var downloadUrl: String
26 |
27 | override fun bindView(
28 | inflater: LayoutInflater,
29 | container: ViewGroup?
30 | ): FragmentApkUpdateBinding {
31 | return FragmentApkUpdateBinding.inflate(inflater, container, false)
32 | }
33 |
34 | override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
35 | super.onViewCreated(view, savedInstanceState)
36 |
37 | // Fetch arguments
38 | version = args.version.ifEmpty {
39 | throw IllegalArgumentException("Version is empty")
40 | }
41 | downloadUrl = args.url.ifEmpty {
42 | throw IllegalArgumentException("Download URL is empty")
43 | }
44 |
45 | // Observe download progress
46 | viewLifecycleOwner.lifecycleScope.launch {
47 | viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
48 | viewModel.downloadState.collect { state ->
49 | updateUI(state)
50 | }
51 | }
52 | }
53 | }
54 |
55 | private fun updateUI(state: DownloadState) {
56 | when (state) {
57 | DownloadState.Idle -> if (apkFileExists()) uiViewDownloads() else uiReadForDownload()
58 | is DownloadState.Downloading -> uiDownloading(state.progress)
59 | DownloadState.Cancelled -> uiReadForDownload()
60 | DownloadState.Completed -> uiViewDownloads()
61 | is DownloadState.Failed -> uiReadForDownload(state.error)
62 | }
63 | }
64 |
65 | private fun apkFileExists(): Boolean {
66 | return ApkDownloadManager.getApkFile(version).exists()
67 | }
68 |
69 | private fun uiReadForDownload(error: String? = null) {
70 | binding.updateDescription.isVisible = true
71 | binding.updateDescription.setText(R.string.update_has_new_apk)
72 |
73 | binding.updateButton.isVisible = true
74 | binding.updateButton.text = getString(R.string.download_update)
75 | binding.updateButton.setOnClickListener { viewModel.startDownload(version, downloadUrl) }
76 |
77 | binding.progressBar.isVisible = false
78 | binding.progressText.isVisible = false
79 |
80 | binding.errorText.text = error
81 | }
82 |
83 | private fun uiDownloading(progress: Int) {
84 | binding.updateDescription.isVisible = true
85 | binding.updateDescription.text = getString(R.string.downloading_update)
86 |
87 | binding.updateButton.isVisible = true
88 | binding.updateButton.text = getString(R.string.cancel)
89 | binding.updateButton.setOnClickListener { viewModel.cancelDownload() }
90 |
91 | binding.progressBar.isVisible = true
92 | binding.progressText.isVisible = true
93 |
94 | binding.errorText.text = null
95 |
96 | if (progress < 0) return
97 |
98 | binding.progressBar.progress = progress
99 | binding.progressText.text = getString(R.string.percentage, progress)
100 | }
101 |
102 | private fun uiViewDownloads() {
103 | binding.updateDescription.isVisible = true
104 | binding.updateDescription.text = getString(R.string.update_download_complete)
105 |
106 | binding.updateButton.isVisible = true
107 | binding.updateButton.text = getString(R.string.open_files_app)
108 | binding.updateButton.setOnClickListener { ApkDownloadManager.viewDownloads(requireContext()) }
109 |
110 | binding.progressBar.isVisible = false
111 | binding.progressText.isVisible = false
112 |
113 | binding.errorText.text = null
114 | }
115 | }
116 |
--------------------------------------------------------------------------------
/app/src/main/res/layout/fragment_privacy.xml:
--------------------------------------------------------------------------------
1 |
2 |
7 |
8 |
16 |
17 |
28 |
29 |
30 |
31 |
41 |
42 |
46 |
47 |
55 |
56 |
65 |
66 |
76 |
77 |
86 |
87 |
96 |
97 |
98 |
99 |
100 |
101 |
--------------------------------------------------------------------------------
/app/src/main/java/com/nthlink/android/client/updates/InAppUpdatePlay.kt:
--------------------------------------------------------------------------------
1 | package com.nthlink.android.client.updates
2 |
3 | import android.app.Activity.RESULT_CANCELED
4 | import android.app.Activity.RESULT_OK
5 | import android.content.Context
6 | import android.util.Log
7 | import androidx.activity.result.ActivityResult
8 | import androidx.activity.result.ActivityResultCallback
9 | import androidx.activity.result.ActivityResultLauncher
10 | import androidx.activity.result.ActivityResultRegistry
11 | import androidx.activity.result.IntentSenderRequest
12 | import androidx.activity.result.contract.ActivityResultContracts.StartIntentSenderForResult
13 | import androidx.lifecycle.LifecycleOwner
14 | import com.google.android.play.core.appupdate.AppUpdateInfo
15 | import com.google.android.play.core.appupdate.AppUpdateManagerFactory
16 | import com.google.android.play.core.appupdate.AppUpdateOptions
17 | import com.google.android.play.core.install.model.ActivityResult.RESULT_IN_APP_UPDATE_FAILED
18 | import com.google.android.play.core.install.model.AppUpdateType
19 | import com.google.android.play.core.install.model.UpdateAvailability
20 | import com.google.android.play.core.install.model.UpdateAvailability.DEVELOPER_TRIGGERED_UPDATE_IN_PROGRESS
21 | import com.nthlink.android.client.App.Companion.TAG
22 | import kotlinx.coroutines.CoroutineScope
23 | import kotlinx.coroutines.Dispatchers.IO
24 | import kotlinx.coroutines.launch
25 | import kotlinx.coroutines.tasks.await
26 |
27 | class InAppUpdatePlay(
28 | context: Context,
29 | private val registry: ActivityResultRegistry,
30 | private val scope: CoroutineScope
31 | ) : InAppUpdate(), ActivityResultCallback {
32 | private val appUpdateManager = AppUpdateManagerFactory.create(context)
33 |
34 | private lateinit var launcher: ActivityResultLauncher
35 |
36 | override fun onCreate(owner: LifecycleOwner) {
37 | super.onCreate(owner)
38 |
39 | launcher = registry.register(
40 | "immediateUpdate",
41 | owner,
42 | StartIntentSenderForResult(),
43 | this
44 | )
45 | }
46 |
47 | override fun onResume(owner: LifecycleOwner) {
48 | super.onResume(owner)
49 |
50 | // Checks that the update is not stalled during 'onResume()'.
51 | scope.launch(IO) {
52 | val appUpdateInfo = appUpdateManager.appUpdateInfo.await()
53 | if (appUpdateInfo.updateAvailability() == DEVELOPER_TRIGGERED_UPDATE_IN_PROGRESS) {
54 | startUpdate(appUpdateInfo)
55 | }
56 | }
57 | }
58 |
59 | override fun checkUpdate(updateIfAvailable: Boolean) {
60 | scope.launch(IO) {
61 | try {
62 | val appUpdateInfo = appUpdateManager.appUpdateInfo.await()
63 | if (appUpdateInfo.isNewUpdateAvailable()) {
64 | _inAppUpdateFlow.emit(InAppUpdateMessage.NewUpdateAvailable)
65 | if (updateIfAvailable) startUpdate(appUpdateInfo)
66 | } else {
67 | _inAppUpdateFlow.emit(InAppUpdateMessage.UpToDate(updateIfAvailable))
68 | }
69 | } catch (e: Throwable) {
70 | Log.e(TAG, "checkUpdate error:", e)
71 | _inAppUpdateFlow.emit(InAppUpdateMessage.CheckFailed(updateIfAvailable))
72 | }
73 | }
74 | }
75 |
76 | private fun AppUpdateInfo.isNewUpdateAvailable(): Boolean {
77 | return updateAvailability() == UpdateAvailability.UPDATE_AVAILABLE
78 | && isUpdateTypeAllowed(AppUpdateType.IMMEDIATE)
79 | }
80 |
81 | private fun startUpdate(appUpdateInfo: AppUpdateInfo) {
82 | appUpdateManager.startUpdateFlowForResult(
83 | appUpdateInfo,
84 | launcher,
85 | AppUpdateOptions.newBuilder(AppUpdateType.IMMEDIATE).build()
86 | )
87 | }
88 |
89 | override fun onActivityResult(result: ActivityResult) {
90 | when (result.resultCode) {
91 | RESULT_OK -> {
92 | // The user has accepted the update.
93 | // For immediate updates, you might not receive this callback because the update should already be finished by the time control is given back to your app.
94 | scope.launch { _inAppUpdateFlow.emit(InAppUpdateMessage.UpdateOk) }
95 | }
96 |
97 | RESULT_CANCELED -> {
98 | // The user has denied or canceled the update.
99 | scope.launch { _inAppUpdateFlow.emit(InAppUpdateMessage.UpdateCanceled) }
100 | }
101 |
102 | RESULT_IN_APP_UPDATE_FAILED -> {
103 | // Some other error prevented either the user from providing consent or the update from proceeding.
104 | scope.launch { _inAppUpdateFlow.emit(InAppUpdateMessage.UpdateFailed) }
105 | }
106 | }
107 | }
108 | }
109 |
--------------------------------------------------------------------------------
/app/src/main/res/values-ar/strings.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 | اقبل واستمر
4 | الاتصال
5 | قطع الاتصال
6 | جاري التهيئة…
7 | توصيل…
8 | جارٍ قطع الاتصال…
9 | تعليق
10 | فئة المشكلة
11 | وصف
12 | سيتم إرسال وصفك وعنوان بريدك الإلكتروني (إن وجد) والمعلومات الإضافية إلى فريق nthLink. يرجى الرجوع إلى سياسة الخصوصية الخاصة بموقع nthLink للاطلاع على سياسة جمع البيانات الخاصة بنا.
13 | إرسال
14 | لا يمكن الاتصال بخادم nthlink.
15 | شكرا لمساعدتنا على التحسين! نحن نحب أن نسمع منك.
16 | عن
17 | الإصدار %s
18 | نحن مجموعة من مهندسي البرمجيات وأمن المعلومات ذوي الخبرة الذين أنشأنا nthLink في عام 2016 لدعم محامي حقوق الإنسان الذين يحتاجون إلى الحصول على معلومات مقيدة والتعبير عن وجهات نظرهم. ومنذ ذلك الحين جعلنا الخدمة متاحة للجمهور على نطاق أوسع. فريق التطوير لدينا هم خبراء في كل من تكنولوجيا أمن الشبكات وموثوقية الخدمة.\n\nخدمتنا مجانية وستظل مجانية بفضل الرعاة والشركاء. هم:\n• Open Technology Fund\n• Google Jigsaw\n• Cure53\n• Include Security\n• Plaintext Design\n\nيتفوق فريق التطوير لدينا في كل من تطور تقنية التحايل على الرقابة وموثوقية الخدمة. مع سنوات من الخبرة في هذا المجال التخصصي ، نوفر للمستخدمين في المناطق الجغرافية المستهدفة وصولًا بسيطًا وآمنًا وموثوقًا إلى المعلومات الخاضعة للرقابة.
19 | اتصل
20 | @string/feedback_page_title
21 | @string/about_page_title
22 | يساعد
23 | قيم التطبيق
24 | نسخ الوصلة
25 | فتح في المتصفح
26 | رابط المشاركة
27 | الصفحة المقصودة
28 | جار التحميل
29 | رابط تم نسخه
30 |
31 | - ملاحظات عامة
32 | - لا يمكن الاتصال
33 | - سرعة الاتصال بطيئة
34 | - اقتراحات
35 | - آخر
36 |
37 | https://www.nthlink.com/#faq
38 | https://nthlink.com/policies/
39 | تحميل
40 | تم النسخ
41 | يرجى لصق المعرف على Telegram
42 | تحديث
43 | تطبيقك محدث
44 | تم إلغاء التحديث
45 | هناك إصدار جديد من nthlink. هل ترغب في التحميل؟
46 | حدث خطأ ما. يرجى المحاولة مرة أخرى.
47 | تابعنا
48 | زيارة
49 | سيؤدي هذا الإجراء إلى فتح إعدادات VPN. هل تريد المتابعة؟\n\nبعد النقر على \"موافق\"، اضغط على رمز الترس بجوار nthlink لتمكين \"VPN دائمًا\" و\"حظر الاتصالات بدون VPN.\"\n\nيرجى التأكد من الاتصال بـ nthlink مرة واحدة على الأقل للتأكد من أن النظام يعرض تطبيق VPN بشكل صحيح.
50 | تشخيص
51 | ابدأ التشخيصات
52 | في nthLink، نطلب منك أحيانًا المساعدة لتحسين خدمتنا من خلال تقديم بيانات تشخيصية.\n\nعند النقر على الزر، تبدأ عملية التشخيص، حيث يتم جمع المعلومات وإرسالها إلى خوادمنا. نؤكد لك أن جميع المعلومات المجمعة مجهولة تمامًا.
53 | بدء التشخيص سيقطع الاتصال الحالي.\n\nهل أنت متأكد أنك تريد المتابعة؟
54 | نسخ معرف التقرير
55 | سياسة الخصوصية
56 | تنزيل التحديث
57 | جارٍ تنزيل التحديث…
58 | اكتمل التنزيل!\nلتحديث nthLink، افتح تطبيق الملفات، وابحث عن أحدث ملف APK الخاص بـ nthLink، ثم اضغط عليه لتثبيته.
59 | افتح تطبيق الملفات
60 |
--------------------------------------------------------------------------------
/app/src/main/res/values-th-rTH/strings.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 | ยอมรับและดำเนินการต่อ
4 | เชื่อมต่อ
5 | ตัดการเชื่อมต่อ
6 | กำลังเริ่มต้น…
7 | กำลังเชื่อมต่อ…
8 | กำลังตัดการเชื่อมต่อ…
9 | ข้อเสนอแนะ
10 | หมวดหมู่ปัญหา
11 | คำอธิบาย
12 | คำอธิบาย ที่อยู่อีเมลของคุณ (หากมี) และข้อมูลเพิ่มเติมจะถูกส่งไปยังทีมงาน nthLink โปรดดูนโยบายความเป็นส่วนตัวของเว็บไซต์ nthLink สำหรับนโยบายการเก็บรวบรวมข้อมูลของเรา
13 | ส่ง
14 | ไม่สามารถเชื่อมต่อกับเซิร์ฟเวอร์ nthlink
15 | ขอบคุณที่ช่วยเราปรับปรุง! เราชอบที่จะได้ยินจากคุณ
16 | เกี่ยวกับ
17 | เวอร์ชัน %s
18 | เราคือกลุ่มวิศวกรซอฟต์แวร์และความปลอดภัยข้อมูลที่มีประสบการณ์ซึ่งก่อตั้ง nthLink ในปี 2559 เพื่อสนับสนุนนักกฎหมายด้านสิทธิมนุษยชนที่ต้องการรับข้อมูลที่ถูกจำกัดและเพื่อแสดงมุมมองของพวกเขา ตั้งแต่นั้นมาเราได้ให้บริการแก่สาธารณชนในวงกว้างขึ้น ทีมพัฒนาของเราเป็นผู้เชี่ยวชาญด้านเทคโนโลยีความปลอดภัยเครือข่ายและความน่าเชื่อถือของบริการ\n\nบริการของเราฟรีและจะยังคงฟรีต่อไป ขอขอบคุณผู้สนับสนุนและพันธมิตรของเรา พวกเขาคือ:\n• Open Technology Fund\n• Google Jigsaw\n• Cure53\n• Include Security\n• Plaintext Design\n\nทีมพัฒนาของเรามีความเป็นเลิศทั้งในด้านความซับซ้อนของเทคโนโลยีการหลบเลี่ยงการเซ็นเซอร์และความน่าเชื่อถือของบริการ ด้วยประสบการณ์หลายปีในด้านพิเศษนี้ เราให้ผู้ใช้ในพื้นที่เป้าหมายทางภูมิศาสตร์ที่เข้าถึงได้ง่าย ปลอดภัย และเชื่อถือได้ในการเข้าถึงข้อมูลที่มีการเซ็นเซอร์อย่างอื่น
19 | เชื่อมต่อ
20 | @string/feedback_page_title
21 | @string/about_page_title
22 | ช่วย
23 | ให้คะแนนแอป
24 | คัดลอกลิงค์
25 | เปิดในเบราว์เซอร์
26 | แบ่งปันลิงค์
27 | หน้า Landing Page
28 | กำลังโหลด
29 | ลิงก์ที่ถูกจัดการ
30 |
31 | - ข้อเสนอแนะทั่วไป
32 | - ไม่สามารถเชื่อมต่อ
33 | - ความเร็วในการเชื่อมต่อช้า
34 | - คำแนะนำ
35 | - อื่น ๆ
36 |
37 | https://www.nthlink.com/#faq
38 | https://nthlink.com/policies/
39 | ดาวน์โหลด
40 | คัดลอกแล้ว
41 | กรุณาวางรหัสบน Telegram
42 | อัปเดต
43 | แอปของคุณเป็นเวอร์ชันล่าสุดแล้ว
44 | การอัปเดตถูกยกเลิก
45 | มีเวอร์ชันใหม่ของ nthlink คุณต้องการดาวน์โหลดหรือไม่?
46 | มีบางอย่างผิดพลาด กรุณาลองใหม่อีกครั้ง
47 | ติดตามเรา
48 | เยี่ยมชม
49 | การดำเนินการนี้จะเปิดการตั้งค่า VPN คุณต้องการดำเนินการต่อหรือไม่?\n\nหลังจากคลิก \"ตกลง\" ให้แตะไอคอนเฟืองข้าง nthlink เพื่อเปิดใช้งาน \"VPN ตลอดเวลา\" และ \"บล็อกการเชื่อมต่อโดยไม่มี VPN\"\n\nโปรดตรวจสอบว่าคุณได้เชื่อมต่อกับ nthlink อย่างน้อยหนึ่งครั้งเพื่อให้มั่นใจว่าระบบแสดงแอป VPN อย่างถูกต้อง
50 | การวินิจฉัย
51 | เริ่มการวินิจฉัย
52 | ที่ nthLink เราอาจขอความร่วมมือจากคุณเป็นครั้งคราวเพื่อปรับปรุงบริการของเราโดยการให้ข้อมูลการวินิจฉัย\n\nเมื่อคลิกปุ่ม กระบวนการวินิจฉัยจะเริ่มขึ้น รวบรวมข้อมูลและส่งไปยังเซิร์ฟเวอร์ของเรา มั่นใจได้ว่าข้อมูลทั้งหมดที่เก็บรวบรวมจะไม่เปิดเผยตัวตน
53 | การเริ่มการวินิจฉัยจะตัดการเชื่อมต่อปัจจุบัน\n\nคุณแน่ใจหรือไม่ว่าต้องการดำเนินการต่อ?
54 | คัดลอก ID รายงาน
55 | นโยบายความเป็นส่วนตัว
56 | ดาวน์โหลดการอัปเดต
57 | กำลังดาวน์โหลดการอัปเดต…
58 | ดาวน์โหลดเสร็จสมบูรณ์!\nในการอัปเดต nthLink ให้เปิดแอปไฟล์ ค้นหา APK ของ nthLink เวอร์ชันล่าสุด แล้วแตะเพื่อติดตั้ง
59 | เปิดแอปไฟล์
60 |
--------------------------------------------------------------------------------
/app/src/main/res/values-fa/strings.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 | بپذیرید و ادامه دهید
4 | اتصال
5 | قطع شدن
6 | در حال راه اندازی…
7 | برقراری ارتباط…
8 | در حال قطع شدن…
9 | بازخورد
10 | دسته بندی موضوع
11 | شرح
12 | توضیحات، آدرس ایمیل شما (در صورت ارائه)، و اطلاعات اضافی برای تیم nthLink ارسال خواهد شد. لطفاً برای سیاست جمع آوری داده های ما به خط مشی رازداری وب سایت nthLink مراجعه کنید.
13 | ارسال
14 | نمی توان به سرور nthlink متصل شد.
15 | از اینکه به ما کمک کردید تا پیشرفت کنیم متشکریم! ما دوست داریم از شما بشنویم.
16 | در باره
17 | نسخه %s
18 | ما گروهی از مهندسین نرم افزار و امنیت اطلاعات با تجربه هستیم که nthLink را در سال 2016 برای حمایت از وکلای حقوق بشری که نیاز به کسب اطلاعات محدود و بیان دیدگاه های خود داشتند، راه اندازی کردیم. از آن زمان ما این سرویس را در دسترس عموم قرار داده ایم. تیم توسعه ما در زمینه فناوری امنیت شبکه و قابلیت اطمینان خدمات متخصص هستند.\n\nخدمات ما رایگان است و به لطف حامیان مالی و شرکای ما رایگان خواهد بود. آن ها هستند:\n• Open Technology Fund\n• Google Jigsaw\n• Cure53\n• Include Security\n• Plaintext Design\n\nتیم توسعه ما هم در پیچیدگی فناوری دور زدن سانسور و هم از نظر قابلیت اطمینان سرویس برتر است. ما با سالها تجربه در این زمینه تخصصی، دسترسی ساده، ایمن و قابل اعتماد به اطلاعات سانسور شده را برای کاربران در مناطق جغرافیایی هدف فراهم می کنیم.
19 | اتصال
20 | @string/feedback_page_title
21 | @string/about_page_title
22 | کمک
23 | امتیاز دادن به برنامه
24 | لینک را کپی کنید
25 | در مرور گر باز کنید
26 | لینک را به اشتراک بگذارید
27 | صفحه فرود
28 | بارگذاری
29 | لینک کپی شده
30 |
31 | - بازخورد عمومی
32 | - نمی تواند وصل شود
33 | - سرعت اتصال پایین است
34 | - پیشنهادات
35 | - سایر
36 |
37 | https://www.nthlink.com/fa/#faq
38 | https://nthlink.com/fa/policies/
39 | دانلود
40 | کپی شد
41 | لطفاً شناسه را در تلگرام جایگذاری کنید.
42 | به روز رسانی
43 | برنامه شما به روز است
44 | بهروزرسانی لغو شد
45 | نسخه جدیدی از nthlink وجود دارد. آیا میخواهید دانلود کنید؟
46 | مشکلی پیش آمده است. لطفاً دوباره امتحان کنید.
47 | ما را دنبال کنید
48 | بازدید
49 | این عملیات تنظیمات VPN را باز میکند. آیا میخواهید ادامه دهید؟\n\nپس از کلیک روی \"تأیید\"، روی نماد چرخ دنده کنار nthlink ضربه بزنید تا \"VPN همیشه فعال\" و \"مسدود کردن اتصالات بدون VPN\" را فعال کنید.\n\nلطفاً حداقل یک بار به nthlink متصل شوید تا مطمئن شوید که سیستم برنامه VPN را به درستی نمایش میدهد.
50 | عیبیابی
51 | شروع عیبیابی
52 | در nthLink، گاهی از شما درخواست میکنیم برای بهبود خدمات ما، دادههای عیبیابی ارائه دهید.\n\nبا کلیک روی دکمه، فرآیند عیبیابی آغاز میشود، اطلاعات جمعآوری شده و به سرورهای ما ارسال میگردد. مطمئن باشید تمام اطلاعات جمعآوری شده کاملاً ناشناس است.
53 | شروع عیبیابی اتصال فعلی را قطع میکند.\n\nآیا مطمئنید که میخواهید ادامه دهید؟
54 | کپی شناسه گزارش
55 | سیاست حفظ حریم خصوصی
56 | دانلود بهروزرسانی
57 | در حال دانلود بهروزرسانی…
58 | دانلود کامل شد!\nبرای بهروزرسانی nthLink، برنامه Files را باز کنید، جدیدترین فایل APK nthLink را پیدا کنید و روی آن ضربه بزنید تا نصب شود.
59 | برنامهٔ فایلها را باز کنید
60 |
--------------------------------------------------------------------------------