diff --git a/.github/ISSUE_TEMPLATE/01-bug-report.yml b/.github/ISSUE_TEMPLATE/01-bug-report.yml
new file mode 100644
index 0000000..c906e0f
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/01-bug-report.yml
@@ -0,0 +1,73 @@
+name: Bug report
+description: File a bug report.
+labels: ['bug']
+assignees: ['karasevm']
+body:
+ - type: markdown
+ attributes:
+ value: |
+ Thanks for taking the time to fill out this bug report!
+ - type: input
+ id: app_version
+ attributes:
+ label: Application Version
+ description: What version of the app are you running?
+ placeholder: ex. 1.0
+ validations:
+ required: true
+ - type: input
+ id: android_version
+ attributes:
+ label: Application Version
+ description: What version of Android you running?
+ placeholder: ex. 13
+ validations:
+ required: true
+ - type: input
+ id: device
+ attributes:
+ label: Device
+ description: What device are you using?
+ placeholder: ex. Pixel 5
+ validations:
+ required: true
+ - type: dropdown
+ id: install_method
+ attributes:
+ label: How do you provide the permission?
+ options:
+ - Shizuku
+ - ADB
+ - Other
+ validations:
+ required: true
+ - type: textarea
+ id: what-happened
+ attributes:
+ label: What happened?
+ description: Also tell us, what did you expect to happen?
+ placeholder: A bug happened!
+ validations:
+ required: true
+ - type: textarea
+ id: steps-to-reproduce
+ attributes:
+ label: Steps to reproduce
+ description: |
+ Please describe what you did to reproduce the bug.
+ - type: textarea
+ id: logs
+ attributes:
+ label: Relevant log output
+ description: Please copy and paste any relevant log output. This will be automatically formatted into code, so no need for backticks.
+ render: shell
+ - type: textarea
+ id: screens
+ attributes:
+ label: Screenshots
+ description: If applicable, add screenshots to help explain your problem.
+ - type: textarea
+ id: additional-context
+ attributes:
+ label: Additional context
+ description: Add any other context about the problem here.
diff --git a/.github/ISSUE_TEMPLATE/02-feature-request.yml b/.github/ISSUE_TEMPLATE/02-feature-request.yml
new file mode 100644
index 0000000..7acccf3
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/02-feature-request.yml
@@ -0,0 +1,30 @@
+name: Feature request
+description: Suggest an idea for this project.
+labels: ["enhancement"]
+assignees: ["karasevm"]
+body:
+ - type: textarea
+ id: problem
+ attributes:
+ label: Is your feature request related to a problem? Please describe.
+ description: A clear and concise description of what the problem is.
+ placeholder: I'm always frustrated when [...]
+ validations:
+ required: true
+ - type: textarea
+ id: solution
+ attributes:
+ label: Describe the solution you'd like.
+ description: A clear and concise description of what you want to happen.
+ validations:
+ required: true
+ - type: textarea
+ id: alternative
+ attributes:
+ label: Describe alternatives you've considered.
+ description: A clear and concise description of any alternative solutions or features you've considered.
+ - type: textarea
+ id: additional-context
+ attributes:
+ label: Additional context
+ description: Add any other context about the problem here.
\ No newline at end of file
diff --git a/.github/ISSUE_TEMPLATE/03-other.yml b/.github/ISSUE_TEMPLATE/03-other.yml
new file mode 100644
index 0000000..6ec8a5e
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/03-other.yml
@@ -0,0 +1,9 @@
+name: Other
+description: If other options don't fit your question.
+body:
+ - type: textarea
+ id: other
+ attributes:
+ label: Ask a question
+ validations:
+ required: true
diff --git a/.gitignore b/.gitignore
index 69eda01..8d9ff13 100644
--- a/.gitignore
+++ b/.gitignore
@@ -3,6 +3,15 @@
*.ap_
*.aab
+# Android Studio generated files and folders
+captures/
+.externalNativeBuild/
+.cxx/
+output.json
+
+# Release dir
+app/release/*
+
# Files for the ART/Dalvik VM
*.dex
@@ -30,23 +39,16 @@ proguard/
# Android Studio Navigation editor temp files
.navigation/
-# Android Studio captures folder
-captures/
-
# IntelliJ
*.iml
-.idea/workspace.xml
-.idea/tasks.xml
-.idea/gradle.xml
-.idea/assetWizardSettings.xml
-.idea/dictionaries
-.idea/libraries
-.idea/caches
+.idea/
+misc.xml
+deploymentTargetDropDown.xml
+render.experimental.xml
# Keystore files
-# Uncomment the following lines if you do not want to check your keystore files in.
-#*.jks
-#*.keystore
+*.jks
+*.keystore
# External native build folder generated in Android Studio 2.2 and later
.externalNativeBuild
@@ -65,3 +67,8 @@ fastlane/Preview.html
fastlane/screenshots
fastlane/test_output
fastlane/readme.md
+
+# kotlin
+.kotlin/
+
+*~
diff --git a/.idea/compiler.xml b/.idea/compiler.xml
index b589d56..b86273d 100644
--- a/.idea/compiler.xml
+++ b/.idea/compiler.xml
@@ -1,6 +1,6 @@
-
+
\ No newline at end of file
diff --git a/.idea/kotlinc.xml b/.idea/kotlinc.xml
index 9a55c2d..d4b7acc 100644
--- a/.idea/kotlinc.xml
+++ b/.idea/kotlinc.xml
@@ -1,6 +1,6 @@
-
+
\ No newline at end of file
diff --git a/.idea/misc.xml b/.idea/misc.xml
deleted file mode 100644
index f7ea510..0000000
--- a/.idea/misc.xml
+++ /dev/null
@@ -1,24 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/.idea/vcs.xml b/.idea/vcs.xml
index 94a25f7..35eb1dd 100644
--- a/.idea/vcs.xml
+++ b/.idea/vcs.xml
@@ -1,6 +1,6 @@
-
+
\ No newline at end of file
diff --git a/README.md b/README.md
index a3ce438..57ac435 100644
--- a/README.md
+++ b/README.md
@@ -1,17 +1,23 @@
+[](https://github.com/karasevm/PrivateDNSAndroid/releases/latest)
+[](https://github.com/karasevm/PrivateDNSAndroid/releases/latest)
+[](https://apt.izzysoft.de/fdroid/index/apk/ru.karasevm.privatednstoggle)
+[](https://hosted.weblate.org/engage/privatednsandroid/)
+
# Private DNS Quick Toggle
A quick settings tile to switch your private dns provider. Supports any number of providers. Makes it easy to turn adblocking dns servers on or off with just
a single tap.
-
+
## Installation
-Get the latest apk on the [releases page](https://github.com/karasevm/PrivateDNSAndroid/releases/latest).
+Get the latest apk on the [releases page](https://github.com/karasevm/PrivateDNSAndroid/releases/latest)
+or from [IzzyOnDroid repo](https://apt.izzysoft.de/fdroid/index/apk/ru.karasevm.privatednstoggle).
-## Automatic (Shizuku)
+### Automatic (Shizuku)
1. Install and start [Shizuku](https://shizuku.rikka.app/).
2. Start the app and allow Shizuku access when prompted.
-## Manual
+### Manual
For the app to work properly you'll need to provide it permissions via ADB:
1. Get to your PC and download platform tools from google [here](https://developer.android.com/studio/releases/platform-tools).
@@ -26,3 +32,18 @@ For the app to work properly you'll need to provide it permissions via ADB:
6. That's it, you should have the app installed.
+## Contributing
+
+### Translation
+The easiest way to contribute would be to submit a translation to your language. Thanks to Weblate gratis hosting for open-source projects you can do it without any programming knowledge on [their website](https://hosted.weblate.org/engage/privatednsandroid/).
+#### Translation status
+
+
+
+
+### Code
+If you want to contribute code please try to adhere to the following guidelines:
+- Include javadoc comments for all the public methods you add
+- Keep the code neatly formatted, you can you the built-in Android Studio formatter
+- Please describe what your code does and how does it do that when sending a PR
+- Before sending a PR please test your change on the oldest and latest supported Android versions (9 and 14 at the time of writing)
diff --git a/app/build.gradle b/app/build.gradle
deleted file mode 100644
index 2272c1a..0000000
--- a/app/build.gradle
+++ /dev/null
@@ -1,57 +0,0 @@
-plugins {
- id 'com.android.application'
- id 'kotlin-android'
-}
-
-android {
- compileSdk 34
-
- defaultConfig {
- applicationId "ru.karasevm.privatednstoggle"
- minSdk 21
- targetSdk 34
- versionCode 9
- versionName "1.2"
-
- testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
- targetSdkVersion 34
- minSdkVersion 28
- }
- buildFeatures {
- viewBinding true
- }
- buildTypes {
- release {
- minifyEnabled true
- shrinkResources true
- proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
- }
- }
- compileOptions {
- sourceCompatibility JavaVersion.VERSION_1_8
- targetCompatibility JavaVersion.VERSION_1_8
- }
- kotlinOptions {
- jvmTarget = '1.8'
- }
- namespace 'ru.karasevm.privatednstoggle'
-}
-
-dependencies {
-
- implementation 'androidx.core:core-ktx:1.12.0'
- implementation 'androidx.appcompat:appcompat:1.6.1'
- implementation 'com.google.android.material:material:1.11.0'
- implementation 'androidx.constraintlayout:constraintlayout:2.1.4'
-
- def shizuku_version = '13.1.5'
- implementation "dev.rikka.shizuku:api:$shizuku_version"
- implementation "dev.rikka.shizuku:provider:$shizuku_version"
-
- implementation 'org.lsposed.hiddenapibypass:hiddenapibypass:4.3'
- compileOnly 'dev.rikka.hidden:stub:4.3.2'
-
- testImplementation 'junit:junit:4.13.2'
- androidTestImplementation 'androidx.test.ext:junit:1.1.5'
- androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1'
-}
\ No newline at end of file
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
new file mode 100644
index 0000000..c06d657
--- /dev/null
+++ b/app/build.gradle.kts
@@ -0,0 +1,88 @@
+plugins {
+ id("com.android.application")
+ id("kotlin-android")
+ id("com.google.devtools.ksp")
+ id("org.jetbrains.kotlin.plugin.serialization")
+}
+
+android {
+ compileSdk = 35
+ androidResources {
+ generateLocaleConfig = true
+ }
+ defaultConfig {
+ applicationId = "ru.karasevm.privatednstoggle"
+ versionCode = 18
+ versionName = "1.10.0"
+
+ testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
+ targetSdk = 35
+ minSdk = 28
+ }
+ buildFeatures {
+ viewBinding = true
+ buildConfig = true
+ }
+ buildTypes {
+ release {
+ isMinifyEnabled = true
+ isShrinkResources = true
+ proguardFiles(
+ getDefaultProguardFile("proguard-android-optimize.txt"),
+ "proguard-rules.pro"
+ )
+ }
+ debug {
+ applicationIdSuffix = ".dev"
+ }
+ }
+ compileOptions {
+ sourceCompatibility = JavaVersion.VERSION_17
+ targetCompatibility = JavaVersion.VERSION_17
+ }
+ kotlinOptions {
+ jvmTarget = "17"
+ }
+ dependenciesInfo {
+ // Disables dependency metadata when building APKs.
+ includeInApk = false
+ // Disables dependency metadata when building Android App Bundles.
+ includeInBundle = false
+ }
+ namespace = "ru.karasevm.privatednstoggle"
+}
+
+dependencies {
+ implementation("androidx.core:core-ktx:1.15.0")
+ implementation("androidx.appcompat:appcompat:1.7.0")
+ implementation("androidx.recyclerview:recyclerview:1.4.0")
+ implementation("androidx.activity:activity-ktx:1.10.0")
+ implementation("androidx.fragment:fragment-ktx:1.8.6")
+ implementation("com.google.android.material:material:1.12.0")
+ implementation("androidx.constraintlayout:constraintlayout:2.2.0")
+ implementation("com.google.guava:guava:33.1.0-android")
+ implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3")
+
+ val shizukuVersion = "13.1.5"
+ implementation("dev.rikka.shizuku:api:$shizukuVersion")
+ implementation("dev.rikka.shizuku:provider:$shizukuVersion")
+ compileOnly("dev.rikka.hidden:stub:4.3.3")
+
+ implementation("org.lsposed.hiddenapibypass:hiddenapibypass:4.3")
+
+ // Room components
+ val roomVersion = "2.6.1"
+ implementation("androidx.room:room-ktx:$roomVersion")
+ ksp("androidx.room:room-compiler:$roomVersion")
+ androidTestImplementation("androidx.room:room-testing:$roomVersion")
+
+ // Lifecycle components
+ val lifecycleVersion = "2.8.7"
+ implementation("androidx.lifecycle:lifecycle-viewmodel-ktx:$lifecycleVersion")
+ implementation("androidx.lifecycle:lifecycle-livedata-ktx:$lifecycleVersion")
+ implementation("androidx.lifecycle:lifecycle-common-java8:$lifecycleVersion")
+
+ testImplementation("junit:junit:4.13.2")
+ androidTestImplementation("androidx.test.ext:junit:1.2.1")
+ androidTestImplementation("androidx.test.espresso:espresso-core:3.6.1")
+}
\ No newline at end of file
diff --git a/app/release/output-metadata.json b/app/release/output-metadata.json
deleted file mode 100644
index 45a0364..0000000
--- a/app/release/output-metadata.json
+++ /dev/null
@@ -1,20 +0,0 @@
-{
- "version": 3,
- "artifactType": {
- "type": "APK",
- "kind": "Directory"
- },
- "applicationId": "ru.karasevm.privatednstoggle",
- "variantName": "release",
- "elements": [
- {
- "type": "SINGLE",
- "filters": [],
- "attributes": [],
- "versionCode": 1,
- "versionName": "1.0",
- "outputFile": "app-release.apk"
- }
- ],
- "elementType": "File"
-}
\ No newline at end of file
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index c2af3f9..21da365 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -3,41 +3,53 @@
xmlns:tools="http://schemas.android.com/tools">
+
+ android:theme="@style/Theme.Transparent">
+
-
-
-
+
+
+
+
+
+
+
-
\ No newline at end of file
diff --git a/app/src/main/java/ru/karasevm/privatednstoggle/AddServerDialogFragment.kt b/app/src/main/java/ru/karasevm/privatednstoggle/AddServerDialogFragment.kt
deleted file mode 100644
index bdc89c8..0000000
--- a/app/src/main/java/ru/karasevm/privatednstoggle/AddServerDialogFragment.kt
+++ /dev/null
@@ -1,75 +0,0 @@
-package ru.karasevm.privatednstoggle
-
-import android.app.Dialog
-import android.content.Context
-import android.os.Bundle
-import androidx.fragment.app.DialogFragment
-import com.google.android.material.dialog.MaterialAlertDialogBuilder
-import ru.karasevm.privatednstoggle.databinding.DialogAddBinding
-
-
-class AddServerDialogFragment : DialogFragment() {
- // Use this instance of the interface to deliver action events
- private lateinit var listener: NoticeDialogListener
-
- private var _binding: DialogAddBinding? = null
-
- // This property is only valid between onCreateView and
- // onDestroyView.
- private val binding get() = _binding!!
-
- /* The activity that creates an instance of this dialog fragment must
- * implement this interface in order to receive event callbacks.
- * Each method passes the DialogFragment in case the host needs to query it. */
- interface NoticeDialogListener {
- fun onDialogPositiveClick(dialog: DialogFragment, server: String)
- }
-
- // Override the Fragment.onAttach() method to instantiate the NoticeDialogListener
- override fun onAttach(context: Context) {
- super.onAttach(context)
- // Verify that the host activity implements the callback interface
- try {
- // Instantiate the NoticeDialogListener so we can send events to the host
- listener = context as NoticeDialogListener
- } catch (e: ClassCastException) {
- // The activity doesn't implement the interface, throw exception
- throw ClassCastException(
- (context.toString() +
- " must implement NoticeDialogListener")
- )
- }
- }
-
- override fun onCreateDialog(
- savedInstanceState: Bundle?
- ): Dialog {
- return activity?.let {
- val builder = MaterialAlertDialogBuilder(it)
- // Get the layout inflater
- val inflater = requireActivity().layoutInflater
- _binding = DialogAddBinding.inflate(inflater)
-
- val view = binding.root
- // Inflate and set the layout for the dialog
- // Pass null as the parent view because its going in the dialog layout
- builder.setTitle(R.string.add_server)
- .setView(view)
- // Add action buttons
- .setPositiveButton(R.string.menu_add
- ) { _, _ ->
- listener.onDialogPositiveClick(
- this,
- binding.editTextServerAddr.text.toString()
- )
- }
- .setNegativeButton(R.string.cancel
- ) { _, _ ->
- dialog?.cancel()
- }
- builder.create()
- } ?: throw IllegalStateException("Activity cannot be null")
- }
-
-
-}
\ No newline at end of file
diff --git a/app/src/main/java/ru/karasevm/privatednstoggle/DnsTileService.kt b/app/src/main/java/ru/karasevm/privatednstoggle/DnsTileService.kt
deleted file mode 100644
index 05da913..0000000
--- a/app/src/main/java/ru/karasevm/privatednstoggle/DnsTileService.kt
+++ /dev/null
@@ -1,223 +0,0 @@
-package ru.karasevm.privatednstoggle
-
-import android.Manifest
-import android.content.pm.PackageManager
-import android.graphics.drawable.Icon
-import android.provider.Settings
-import android.service.quicksettings.Tile
-import android.service.quicksettings.TileService
-import android.widget.Toast
-
-
-const val DNS_MODE_OFF = "off"
-const val DNS_MODE_AUTO = "opportunistic"
-const val DNS_MODE_PRIVATE = "hostname"
-
-class DnsTileService : TileService() {
-
-
- private fun checkForPermission(): Boolean {
- if (checkSelfPermission(Manifest.permission.WRITE_SECURE_SETTINGS) == PackageManager.PERMISSION_GRANTED) {
- return true
- }
- Toast.makeText(this, R.string.permission_missing, Toast.LENGTH_SHORT).show()
- return false
- }
-
- override fun onTileAdded() {
- super.onTileAdded()
- checkForPermission()
- // Update state
- qsTile.state = Tile.STATE_INACTIVE
-
- // Update looks
- qsTile.updateTile()
- }
-
- override fun onClick() {
- super.onClick()
- if (!checkForPermission()) {
- return
- }
-
- val dnsMode = Settings.Global.getString(contentResolver, "private_dns_mode")
- val dnsProvider = Settings.Global.getString(contentResolver, "private_dns_specifier")
-
- val sharedPrefs = this.getSharedPreferences("app_prefs", 0)
- val autoEnabled = sharedPrefs.getBoolean("auto_enabled", false)
-
-
- if (dnsMode.equals(DNS_MODE_OFF, ignoreCase = true)) {
-// refreshTile(qsTile, Tile.STATE_INACTIVE, getString(R.string.dns_off), R.drawable.ic_off_black_24dp)
- if (autoEnabled) {
- changeTileState(
- qsTile,
- Tile.STATE_ACTIVE,
- getString(R.string.dns_auto),
- R.drawable.ic_auto_black_24dp,
- DNS_MODE_AUTO,
- dnsProvider
- )
- } else {
- changeTileState(
- qsTile,
- Tile.STATE_ACTIVE,
- getNextAddress(dnsProvider),
- R.drawable.ic_private_black_24dp,
- DNS_MODE_PRIVATE,
- getNextAddress(dnsProvider)
- )
-
- }
-
- } else if (dnsMode == null || dnsMode.equals(DNS_MODE_AUTO, ignoreCase = true)) {
- changeTileState(
- qsTile,
- Tile.STATE_ACTIVE,
- getNextAddress(dnsProvider),
- R.drawable.ic_private_black_24dp,
- DNS_MODE_PRIVATE,
- getNextAddress(dnsProvider)
- )
- } else if (dnsMode.equals(DNS_MODE_PRIVATE, ignoreCase = true)) {
- if (getNextAddress(dnsProvider) == null) {
- changeTileState(
- qsTile,
- Tile.STATE_INACTIVE,
- getString(R.string.dns_off),
- R.drawable.ic_off_black_24dp,
- DNS_MODE_OFF,
- getNextAddress(dnsProvider)
- )
- } else {
- changeTileState(
- qsTile,
- Tile.STATE_ACTIVE,
- getNextAddress(dnsProvider),
- R.drawable.ic_private_black_24dp,
- DNS_MODE_PRIVATE,
- getNextAddress(dnsProvider)
- )
- }
- }
-
- }
-
- override fun onStartListening() {
- super.onStartListening()
- if (!checkForPermission()) {
- return
- }
- val dnsMode = Settings.Global.getString(contentResolver, "private_dns_mode")
-
- // Prevent some crashes
- if (qsTile == null) {
- return
- }
-
- if (dnsMode.equals(DNS_MODE_OFF, ignoreCase = true)) {
- refreshTile(
- qsTile,
- Tile.STATE_INACTIVE,
- getString(R.string.dns_off),
- R.drawable.ic_off_black_24dp
- )
- } else if (dnsMode == null) {
- refreshTile(
- qsTile,
- Tile.STATE_INACTIVE,
- getString(R.string.dns_unknown),
- R.drawable.ic_unknown_black_24dp
- )
- } else if (dnsMode.equals(DNS_MODE_AUTO, ignoreCase = true)) {
- refreshTile(
- qsTile,
- Tile.STATE_ACTIVE,
- getString(R.string.dns_auto),
- R.drawable.ic_auto_black_24dp
- )
- } else if (dnsMode.equals(DNS_MODE_PRIVATE, ignoreCase = true)) {
- val dnsProvider = Settings.Global.getString(contentResolver, "private_dns_specifier")
- if (dnsProvider != null) {
- refreshTile(
- qsTile,
- Tile.STATE_ACTIVE,
- dnsProvider,
- R.drawable.ic_private_black_24dp
- )
- } else {
- Toast.makeText(this, R.string.permission_missing, Toast.LENGTH_SHORT).show()
- }
- }
-
- }
-
- /**
- * Updates tile to specified parameters
- *
- * @param tile tile to update
- * @param state tile state
- * @param label tile label
- * @param icon tile icon
- */
- private fun refreshTile(tile: Tile, state: Int, label: String?, icon: Int) {
- tile.state = state
- tile.label = label
- tile.icon = Icon.createWithResource(this, icon)
- tile.updateTile()
- }
-
- /**
- * Updates tile and system settings to specified parameters
- *
- * @param tile tile to update
- * @param state tile state
- * @param label tile label
- * @param icon tile icon
- * @param dnsMode system dns mode
- * @param dnsProvider system dns provider
- */
- private fun changeTileState(
- tile: Tile,
- state: Int,
- label: String?,
- icon: Int,
- dnsMode: String,
- dnsProvider: String?
- ) {
- tile.label = label
- tile.state = state
- tile.icon = Icon.createWithResource(this, icon)
- Settings.Global.putString(contentResolver, "private_dns_mode", dnsMode)
- Settings.Global.putString(contentResolver, "private_dns_specifier", dnsProvider)
- tile.updateTile()
- }
-
- /**
- * Gets next dns address from preferences,
- * if current address is last returns null
- *
- * @param currentAddress currently set address
- * @return next address
- */
- private fun getNextAddress(currentAddress: String?): String? {
- val sharedPrefs = this.getSharedPreferences("app_prefs", 0)
- val items = sharedPrefs.getString("dns_servers", "dns.google")!!.split(",").toMutableList()
-
- // Fallback if list is empty
- if (items[0] == "") {
- items.removeAt(0)
- items.add("dns.google")
- }
-
- val index = items.indexOf(currentAddress)
-
- if (index == -1 || currentAddress == null) {
- return items[0]
- }
- if (index == items.size - 1) {
- return null
- }
- return items[index + 1]
- }
-}
\ No newline at end of file
diff --git a/app/src/main/java/ru/karasevm/privatednstoggle/MainActivity.kt b/app/src/main/java/ru/karasevm/privatednstoggle/MainActivity.kt
deleted file mode 100644
index c09f490..0000000
--- a/app/src/main/java/ru/karasevm/privatednstoggle/MainActivity.kt
+++ /dev/null
@@ -1,215 +0,0 @@
-package ru.karasevm.privatednstoggle
-
-import android.Manifest
-import android.annotation.SuppressLint
-import android.content.Intent
-import android.content.SharedPreferences
-import android.content.pm.IPackageManager
-import android.content.pm.PackageManager
-import android.net.Uri
-import android.os.Build
-import android.os.Bundle
-import android.permission.IPermissionManager
-import android.util.Log
-import android.view.Menu
-import android.view.MenuItem
-import android.widget.Toast
-import androidx.appcompat.app.AppCompatActivity
-import androidx.fragment.app.DialogFragment
-import androidx.recyclerview.widget.LinearLayoutManager
-import org.lsposed.hiddenapibypass.HiddenApiBypass
-import rikka.shizuku.Shizuku
-import rikka.shizuku.ShizukuBinderWrapper
-import rikka.shizuku.ShizukuProvider
-import rikka.shizuku.SystemServiceHelper
-import ru.karasevm.privatednstoggle.databinding.ActivityMainBinding
-
-
-class MainActivity : AppCompatActivity(), AddServerDialogFragment.NoticeDialogListener, DeleteServerDialogFragment.NoticeDialogListener, Shizuku.OnRequestPermissionResultListener {
-
- private lateinit var linearLayoutManager: LinearLayoutManager
- private lateinit var binding: ActivityMainBinding
- private var items = mutableListOf()
- private lateinit var sharedPrefs: SharedPreferences
- private lateinit var adapter: RecyclerAdapter
-
- override fun onCreate(savedInstanceState: Bundle?) {
- super.onCreate(savedInstanceState)
-
- Shizuku.addRequestPermissionResultListener(this::onRequestPermissionResult)
-
- binding = ActivityMainBinding.inflate(layoutInflater)
- val view = binding.root
- setContentView(view)
-
- linearLayoutManager = LinearLayoutManager(this)
- binding.recyclerView.layoutManager = linearLayoutManager
-
- sharedPrefs = this.getSharedPreferences("app_prefs", 0)
-
- items = sharedPrefs.getString("dns_servers", "")!!.split(",").toMutableList()
- if (items[0] == "") {
- items.removeAt(0)
- }
- adapter = RecyclerAdapter(items)
- adapter.onItemClick = { position ->
- val newFragment = DeleteServerDialogFragment(position)
- newFragment.show(supportFragmentManager, "delete_server")
- }
- binding.floatingActionButton.setOnClickListener {
- val newFragment = AddServerDialogFragment()
- newFragment.show(supportFragmentManager, "add_server")
- }
- binding.recyclerView.adapter = adapter
-
- binding.topAppBar.setOnMenuItemClickListener { item ->
- when (item.itemId) {
- R.id.privacy_policy -> {
- val browserIntent = Intent(Intent.ACTION_VIEW, Uri.parse("https://karasevm.github.io/PrivateDNSAndroid/privacy_policy"))
- startActivity(browserIntent)
- true
- }
-
- R.id.enable_auto -> {
- if (!item.isChecked){
- Toast.makeText(this, R.string.auto_mode_clarification, Toast.LENGTH_LONG).show()
- }
- sharedPrefs.edit().putBoolean("auto_enabled", !item.isChecked).apply()
- item.setChecked(!item.isChecked)
- true
- }
- else -> true
- }
- }
- }
-
- override fun onCreateOptionsMenu(menu: Menu?): Boolean {
-
- val curVal = sharedPrefs.getBoolean("auto_enabled", false)
- menuInflater.inflate(R.menu.menu_main, menu)
- menu?.findItem(R.id.enable_auto)?.setChecked(curVal)
-
- return true
- }
-
- override fun onResume() {
- super.onResume()
- // Check if WRITE_SECURE_SETTINGS is granted
- if (checkSelfPermission(Manifest.permission.WRITE_SECURE_SETTINGS) != PackageManager.PERMISSION_GRANTED) {
- // Check if Shizuku is available
- if (Shizuku.pingBinder()) {
- // check if permission is granted already
- val isGranted = if (Shizuku.isPreV11() || Shizuku.getVersion() < 11) {
- checkSelfPermission(ShizukuProvider.PERMISSION) == PackageManager.PERMISSION_GRANTED
- } else {
- Shizuku.checkSelfPermission() == PackageManager.PERMISSION_GRANTED
- }
- // request permission if not granted
- if (!isGranted && !Shizuku.shouldShowRequestPermissionRationale()) {
- if (Shizuku.isPreV11() || Shizuku.getVersion() < 11) {
- requestPermissions(arrayOf(ShizukuProvider.PERMISSION), 1)
- } else {
- Shizuku.requestPermission(1)
- }
- } else {
- // if shizuku permission is granted, but WRITE_SECURE_SETTINGS is not, grant it
- try {
- grantPermissionWithShizuku()
- } catch (exception: Exception) {
- Log.e("SHIZUKU", "onRequestPermissionResult: ", exception)
- val browserIntent = Intent(Intent.ACTION_VIEW, Uri.parse("https://karasevm.github.io/PrivateDNSAndroid/"))
- startActivity(browserIntent)
- finish()
- }
- }
- } else {
- if (checkSelfPermission(Manifest.permission.WRITE_SECURE_SETTINGS) != PackageManager.PERMISSION_GRANTED) {
- val browserIntent = Intent(
- Intent.ACTION_VIEW,
- Uri.parse("https://karasevm.github.io/PrivateDNSAndroid/")
- )
- startActivity(browserIntent)
- finish()
- }
- }
- }
- }
-
- override fun onDestroy() {
- super.onDestroy()
- Shizuku.removeRequestPermissionResultListener(this::onRequestPermissionResult)
- }
-
- override fun onDialogPositiveClick(dialog: DialogFragment, server: String) {
- if (server.isEmpty()) {
- Toast.makeText(this, R.string.server_length_error, Toast.LENGTH_SHORT).show()
- return
- }
- items.add(server)
- adapter.setData(items.toMutableList())
- binding.recyclerView.adapter?.notifyItemInserted(items.size - 1)
- sharedPrefs.edit()
- .putString("dns_servers", items.joinToString(separator = ",") { it }).apply()
- }
-
- override fun onDialogPositiveClick(dialog: DialogFragment,position: Int) {
- items.removeAt(position)
- adapter.setData(items.toMutableList())
- adapter.notifyItemRemoved(position)
- sharedPrefs.edit()
- .putString("dns_servers", items.joinToString(separator = ",") { it }).apply()
- }
-
- /**
- * Attempts to grant WRITE_SECURE_SETTINGS permission with Shizuku
- */
- private fun grantPermissionWithShizuku() {
- val packageName = "ru.karasevm.privatednstoggle"
- if (Build.VERSION.SDK_INT >= 31) {
- HiddenApiBypass.addHiddenApiExemptions(
- "Landroid/permission"
- )
- val binder =
- ShizukuBinderWrapper(SystemServiceHelper.getSystemService("permissionmgr"))
- val pm = IPermissionManager.Stub.asInterface(binder)
- pm.grantRuntimePermission(
- packageName,
- Manifest.permission.WRITE_SECURE_SETTINGS,
- 0
- )
- } else {
- val binder = ShizukuBinderWrapper(SystemServiceHelper.getSystemService("package"))
- val pm = IPackageManager.Stub.asInterface(binder)
- pm.grantRuntimePermission(
- packageName,
- Manifest.permission.WRITE_SECURE_SETTINGS,
- 0
- )
- }
- if (checkSelfPermission(Manifest.permission.WRITE_SECURE_SETTINGS) != PackageManager.PERMISSION_GRANTED) {
- val browserIntent = Intent(Intent.ACTION_VIEW, Uri.parse("https://karasevm.github.io/PrivateDNSAndroid/"))
- startActivity(browserIntent)
- finish()
- }
- }
- @SuppressLint("PrivateApi")
- override fun onRequestPermissionResult(requestCode: Int, grantResult: Int) {
- val isGranted = grantResult == PackageManager.PERMISSION_GRANTED
-
- if (isGranted) {
- try {
- grantPermissionWithShizuku()
- } catch (exception: Exception) {
- Log.e("SHIZUKU", "onRequestPermissionResult: ", exception)
- val browserIntent = Intent(Intent.ACTION_VIEW, Uri.parse("https://karasevm.github.io/PrivateDNSAndroid/"))
- startActivity(browserIntent)
- finish()
- }
-
- } else if (checkSelfPermission(Manifest.permission.WRITE_SECURE_SETTINGS) != PackageManager.PERMISSION_GRANTED) {
- val browserIntent = Intent(Intent.ACTION_VIEW, Uri.parse("https://karasevm.github.io/PrivateDNSAndroid/"))
- startActivity(browserIntent)
- finish()
- }
- }
-}
\ No newline at end of file
diff --git a/app/src/main/java/ru/karasevm/privatednstoggle/PrivateDNSApp.kt b/app/src/main/java/ru/karasevm/privatednstoggle/PrivateDNSApp.kt
index fd28566..c404503 100644
--- a/app/src/main/java/ru/karasevm/privatednstoggle/PrivateDNSApp.kt
+++ b/app/src/main/java/ru/karasevm/privatednstoggle/PrivateDNSApp.kt
@@ -1,11 +1,33 @@
package ru.karasevm.privatednstoggle
import android.app.Application
+import android.os.StrictMode
import com.google.android.material.color.DynamicColors
+import ru.karasevm.privatednstoggle.data.DnsServerRepository
+import ru.karasevm.privatednstoggle.data.database.DnsServerRoomDatabase
class PrivateDNSApp : Application() {
+
+ private val database by lazy { DnsServerRoomDatabase.getDatabase(this) }
+ val repository by lazy { DnsServerRepository(database.dnsServerDao()) }
+
override fun onCreate() {
super.onCreate()
DynamicColors.applyToActivitiesIfAvailable(this)
+
+ if (BuildConfig.DEBUG){
+ StrictMode.setThreadPolicy(
+ StrictMode.ThreadPolicy.Builder()
+ .detectAll()
+ .penaltyLog()
+ .build()
+ )
+ StrictMode.setVmPolicy(
+ StrictMode.VmPolicy.Builder()
+ .detectAll()
+ .penaltyLog()
+ .build()
+ )
+ }
}
}
\ No newline at end of file
diff --git a/app/src/main/java/ru/karasevm/privatednstoggle/RecyclerAdapter.kt b/app/src/main/java/ru/karasevm/privatednstoggle/RecyclerAdapter.kt
deleted file mode 100644
index e0949a8..0000000
--- a/app/src/main/java/ru/karasevm/privatednstoggle/RecyclerAdapter.kt
+++ /dev/null
@@ -1,50 +0,0 @@
-package ru.karasevm.privatednstoggle
-
-import android.content.Context
-import android.view.LayoutInflater
-import android.view.View
-import android.view.ViewGroup
-import android.widget.TextView
-import androidx.recyclerview.widget.RecyclerView
-import java.util.*
-
-class RecyclerAdapter(val items: MutableList): RecyclerView.Adapter() {
-
- var onItemClick: ((Int) -> Unit)? = null
-
- override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerAdapter.ViewHolder {
- val view = LayoutInflater.from(parent.context).inflate(R.layout.recyclerview_row, parent, false)
- val vh = ViewHolder(view)
- return vh
- }
-
- override fun onBindViewHolder(holder: RecyclerAdapter.ViewHolder, position: Int) {
- val item = items[position]
-
- // sets the text to the textview from our itemHolder class
- holder.textView.text = item
- }
-
- override fun getItemCount(): Int {
- return items.size
- }
-
-
-
- inner class ViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
- val textView: TextView = itemView.findViewById(R.id.textView)
- init {
- itemView.setOnClickListener {
- onItemClick?.invoke(adapterPosition)
- }
- }
- }
- fun setData(newItems: MutableList) {
- items.run {
- clear()
- addAll(newItems)
- }
- }
-
-
-}
\ No newline at end of file
diff --git a/app/src/main/java/ru/karasevm/privatednstoggle/data/DnsServerDao.kt b/app/src/main/java/ru/karasevm/privatednstoggle/data/DnsServerDao.kt
new file mode 100644
index 0000000..026de12
--- /dev/null
+++ b/app/src/main/java/ru/karasevm/privatednstoggle/data/DnsServerDao.kt
@@ -0,0 +1,84 @@
+package ru.karasevm.privatednstoggle.data
+
+import androidx.room.Dao
+import androidx.room.Query
+import androidx.room.Transaction
+import kotlinx.coroutines.flow.Flow
+import ru.karasevm.privatednstoggle.model.DnsServer
+
+@Dao
+interface DnsServerDao {
+
+ @Query("SELECT * FROM dns_servers ORDER BY sortOrder ASC")
+ fun getAll(): Flow>
+
+ @Query("SELECT * FROM dns_servers WHERE enabled = 1 ORDER BY sortOrder ASC LIMIT 1")
+ suspend fun getFirstEnabled(): DnsServer
+
+ @Query("SELECT * FROM dns_servers WHERE server = :server LIMIT 1")
+ suspend fun getFirstByServer(server: String): DnsServer?
+
+ @Query("SELECT * FROM dns_servers WHERE id = :id")
+ suspend fun getById(id: Int): DnsServer?
+
+ @Query("SELECT * FROM dns_servers " +
+ "WHERE sortOrder > (SELECT sortOrder FROM dns_servers WHERE server = :server) AND enabled = 1 " +
+ "ORDER BY sortOrder ASC " +
+ "LIMIT 1")
+ suspend fun getNextEnabledByServer(server: String): DnsServer?
+
+ @Query("DELETE FROM dns_servers")
+ suspend fun deleteAll()
+
+ @Query("DELETE FROM dns_servers WHERE id = :id")
+ suspend fun deleteById(id: Int)
+
+ @Query("UPDATE dns_servers SET sortOrder = sortOrder + 1 " +
+ "WHERE sortOrder >= :startSortOrder AND sortOrder <= :endSortOrder")
+ suspend fun incrementSortOrder(startSortOrder: Int, endSortOrder: Int = Int.MAX_VALUE)
+
+ @Query("UPDATE dns_servers SET sortOrder = sortOrder - 1 " +
+ "WHERE sortOrder >= :startSortOrder AND sortOrder <= :endSortOrder")
+ suspend fun decrementSortOrder(startSortOrder: Int, endSortOrder: Int = Int.MAX_VALUE)
+
+ @Query("UPDATE dns_servers SET sortOrder = sortOrder - 1 " +
+ "WHERE sortOrder > (SELECT sortOrder FROM dns_servers WHERE id = :id)")
+ suspend fun decrementSortOrderById(id: Int)
+
+ @Transaction
+ suspend fun deleteAndDecrement(id: Int) {
+ decrementSortOrderById(id)
+ deleteById(id)
+ }
+
+ @Query("UPDATE dns_servers SET label = :label WHERE id = :id")
+ suspend fun updateLabel(id: Int, label: String)
+
+ @Query("UPDATE dns_servers SET server = :server WHERE id = :id")
+ suspend fun updateServer(id: Int, server: String)
+
+ @Query("UPDATE dns_servers " +
+ "SET server = COALESCE(:server, server), " +
+ " label = COALESCE(:label, label), " +
+ " sortOrder = COALESCE(:sortOrder, sortOrder), " +
+ " enabled = COALESCE(:enabled, enabled) " +
+ "WHERE id = :id")
+ suspend fun update(id: Int, server: String?, label: String?, sortOrder: Int?, enabled: Boolean?)
+
+ @Transaction
+ suspend fun moveUp(sortOrder: Int, newSortOrder: Int, id: Int){
+ incrementSortOrder(newSortOrder, sortOrder)
+ update(id, null, null, newSortOrder, null)
+ }
+
+ @Transaction
+ suspend fun moveDown(sortOrder: Int, newSortOrder: Int, id: Int){
+ decrementSortOrder(sortOrder, newSortOrder)
+ update(id, null, null, newSortOrder, null)
+ }
+
+ @Query("INSERT INTO dns_servers(server, label, sortOrder, enabled) " +
+ "VALUES(:server, :label, COALESCE((SELECT MAX(sortOrder) + 1 FROM dns_servers), 0), :enabled)")
+ suspend fun insert(server: String, label: String, enabled: Boolean)
+
+}
\ No newline at end of file
diff --git a/app/src/main/java/ru/karasevm/privatednstoggle/data/DnsServerRepository.kt b/app/src/main/java/ru/karasevm/privatednstoggle/data/DnsServerRepository.kt
new file mode 100644
index 0000000..4c7ddd4
--- /dev/null
+++ b/app/src/main/java/ru/karasevm/privatednstoggle/data/DnsServerRepository.kt
@@ -0,0 +1,60 @@
+package ru.karasevm.privatednstoggle.data
+
+import androidx.annotation.WorkerThread
+import kotlinx.coroutines.flow.Flow
+import ru.karasevm.privatednstoggle.model.DnsServer
+
+class DnsServerRepository(private val dnsServerDao: DnsServerDao) {
+
+ val allServers: Flow> = dnsServerDao.getAll()
+
+ @WorkerThread
+ fun getAll() = dnsServerDao.getAll()
+
+ @WorkerThread
+ suspend fun getFirstEnabled() = dnsServerDao.getFirstEnabled()
+
+ @WorkerThread
+ suspend fun getById(id: Int) = dnsServerDao.getById(id)
+
+ @WorkerThread
+ suspend fun getFirstByServer(server: String) = dnsServerDao.getFirstByServer(server)
+
+ @WorkerThread
+ suspend fun getNextByServer(server: String) = dnsServerDao.getNextEnabledByServer(server)
+
+ @WorkerThread
+ suspend fun insert(dnsServer: DnsServer) {
+ dnsServerDao.insert(dnsServer.server, dnsServer.label, dnsServer.enabled)
+ }
+
+ @WorkerThread
+ suspend fun update(
+ id: Int,
+ server: String?,
+ label: String?,
+ sortOrder: Int?,
+ enabled: Boolean?
+ ) {
+ dnsServerDao.update(id, server, label, sortOrder, enabled)
+ }
+
+ @WorkerThread
+ suspend fun move(sortOrder: Int, newSortOrder: Int, id: Int) {
+ if (sortOrder == newSortOrder) {
+ return
+ }
+ if (newSortOrder > sortOrder) {
+ dnsServerDao.moveDown(sortOrder, newSortOrder, id)
+ } else {
+ dnsServerDao.moveUp(sortOrder, newSortOrder, id)
+ }
+ }
+
+ @WorkerThread
+ suspend fun delete(id: Int) {
+ dnsServerDao.deleteAndDecrement(id)
+ }
+
+
+}
\ No newline at end of file
diff --git a/app/src/main/java/ru/karasevm/privatednstoggle/data/DnsServerViewModel.kt b/app/src/main/java/ru/karasevm/privatednstoggle/data/DnsServerViewModel.kt
new file mode 100644
index 0000000..bd1987a
--- /dev/null
+++ b/app/src/main/java/ru/karasevm/privatednstoggle/data/DnsServerViewModel.kt
@@ -0,0 +1,48 @@
+package ru.karasevm.privatednstoggle.data
+
+import androidx.lifecycle.LiveData
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.ViewModelProvider
+import androidx.lifecycle.asLiveData
+import androidx.lifecycle.viewModelScope
+import kotlinx.coroutines.launch
+import ru.karasevm.privatednstoggle.model.DnsServer
+
+class DnsServerViewModel(private val dnsServerRepository: DnsServerRepository) : ViewModel() {
+
+ val allServers: LiveData> = dnsServerRepository.allServers.asLiveData()
+
+ fun getAll() = dnsServerRepository.getAll()
+
+ suspend fun getById(id: Int) = dnsServerRepository.getById(id)
+
+ fun insert(dnsServer: DnsServer) =
+ viewModelScope.launch {
+ dnsServerRepository.insert(dnsServer)
+ }
+
+ fun update(
+ id: Int,
+ server: String? = null,
+ label: String? = null,
+ sortOrder: Int? = null,
+ enabled: Boolean? = null
+ ) = viewModelScope.launch { dnsServerRepository.update(id, server, label, sortOrder, enabled) }
+
+ fun move(sortOrder: Int, newSortOrder: Int, id: Int) =
+ viewModelScope.launch { dnsServerRepository.move(sortOrder, newSortOrder, id) }
+
+ fun delete(id: Int) = viewModelScope.launch { dnsServerRepository.delete(id) }
+
+}
+
+class DnsServerViewModelFactory(private val dnsServerRepository: DnsServerRepository) :
+ ViewModelProvider.Factory {
+ override fun create(modelClass: Class): T {
+ if (modelClass.isAssignableFrom(DnsServerViewModel::class.java)) {
+ @Suppress("UNCHECKED_CAST")
+ return DnsServerViewModel(dnsServerRepository) as T
+ }
+ throw IllegalArgumentException("Unknown ViewModel class")
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/ru/karasevm/privatednstoggle/data/database/DnsServerRoomDatabase.kt b/app/src/main/java/ru/karasevm/privatednstoggle/data/database/DnsServerRoomDatabase.kt
new file mode 100644
index 0000000..72b6f25
--- /dev/null
+++ b/app/src/main/java/ru/karasevm/privatednstoggle/data/database/DnsServerRoomDatabase.kt
@@ -0,0 +1,34 @@
+package ru.karasevm.privatednstoggle.data.database
+
+import android.content.Context
+import androidx.room.Database
+import androidx.room.Room
+import androidx.room.RoomDatabase
+import ru.karasevm.privatednstoggle.data.DnsServerDao
+import ru.karasevm.privatednstoggle.model.DnsServer
+
+@Database(entities = [DnsServer::class], version = 1, exportSchema = false)
+abstract class DnsServerRoomDatabase : RoomDatabase() {
+
+ abstract fun dnsServerDao(): DnsServerDao
+
+ companion object {
+ @Volatile
+ private var INSTANCE: DnsServerRoomDatabase? = null
+ fun getDatabase(context: Context): DnsServerRoomDatabase {
+ val tempInstance = INSTANCE
+ if (tempInstance != null) {
+ return tempInstance
+ }
+ synchronized(this) {
+ val instance = Room.databaseBuilder(
+ context.applicationContext,
+ DnsServerRoomDatabase::class.java,
+ "dns_server_database"
+ ).build()
+ INSTANCE = instance
+ return instance
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/ru/karasevm/privatednstoggle/model/DnsServer.kt b/app/src/main/java/ru/karasevm/privatednstoggle/model/DnsServer.kt
new file mode 100644
index 0000000..38c1be4
--- /dev/null
+++ b/app/src/main/java/ru/karasevm/privatednstoggle/model/DnsServer.kt
@@ -0,0 +1,24 @@
+package ru.karasevm.privatednstoggle.model
+
+import androidx.room.ColumnInfo
+import androidx.room.Entity
+import androidx.room.PrimaryKey
+import kotlinx.serialization.SerialName
+import kotlinx.serialization.Serializable
+
+// All fields must have default values for proper deserialization
+@Serializable
+@Entity(tableName = "dns_servers")
+data class DnsServer(
+ @SerialName("id")
+ @PrimaryKey(autoGenerate = true)
+ val id: Int = 0,
+ @SerialName("server")
+ val server: String = "",
+ @SerialName("label")
+ val label: String = "",
+ @SerialName("enabled")
+ @ColumnInfo(defaultValue = "1")
+ val enabled: Boolean = true,
+ val sortOrder: Int? = null
+)
diff --git a/app/src/main/java/ru/karasevm/privatednstoggle/service/DnsTileService.kt b/app/src/main/java/ru/karasevm/privatednstoggle/service/DnsTileService.kt
new file mode 100644
index 0000000..1bb1944
--- /dev/null
+++ b/app/src/main/java/ru/karasevm/privatednstoggle/service/DnsTileService.kt
@@ -0,0 +1,302 @@
+package ru.karasevm.privatednstoggle.service
+
+import android.content.BroadcastReceiver
+import android.content.Context
+import android.content.Intent
+import android.content.IntentFilter
+import android.graphics.drawable.Icon
+import android.provider.Settings
+import android.service.quicksettings.Tile
+import android.service.quicksettings.TileService
+import android.util.Log
+import androidx.core.content.ContextCompat
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.cancelChildren
+import kotlinx.coroutines.launch
+import ru.karasevm.privatednstoggle.PrivateDNSApp
+import ru.karasevm.privatednstoggle.R
+import ru.karasevm.privatednstoggle.data.DnsServerRepository
+import ru.karasevm.privatednstoggle.model.DnsServer
+import ru.karasevm.privatednstoggle.util.PreferenceHelper
+import ru.karasevm.privatednstoggle.util.PreferenceHelper.autoMode
+import ru.karasevm.privatednstoggle.util.PreferenceHelper.requireUnlock
+import ru.karasevm.privatednstoggle.util.PrivateDNSUtils
+import ru.karasevm.privatednstoggle.util.PrivateDNSUtils.AUTO_MODE_OPTION_AUTO
+import ru.karasevm.privatednstoggle.util.PrivateDNSUtils.AUTO_MODE_OPTION_OFF_AUTO
+import ru.karasevm.privatednstoggle.util.PrivateDNSUtils.AUTO_MODE_OPTION_PRIVATE
+import ru.karasevm.privatednstoggle.util.PrivateDNSUtils.DNS_MODE_AUTO
+import ru.karasevm.privatednstoggle.util.PrivateDNSUtils.DNS_MODE_OFF
+import ru.karasevm.privatednstoggle.util.PrivateDNSUtils.DNS_MODE_PRIVATE
+import ru.karasevm.privatednstoggle.util.PrivateDNSUtils.checkForPermission
+
+class DnsTileService : TileService() {
+
+ private val repository: DnsServerRepository by lazy { (application as PrivateDNSApp).repository }
+ private val job = SupervisorJob()
+ private val scope = CoroutineScope(Dispatchers.IO + job)
+ private val sharedPreferences by lazy { PreferenceHelper.defaultPreference(this) }
+ private var isBroadcastReceiverRegistered = false
+
+ override fun onTileAdded() {
+ super.onTileAdded()
+ checkForPermission(this)
+ // Update state
+ qsTile.state = Tile.STATE_INACTIVE
+
+ // Update looks
+ qsTile.updateTile()
+ }
+
+ /**
+ * Set's the state of the tile and system settings to the next state
+ */
+ private fun cycleState() {
+ val dnsMode = Settings.Global.getString(contentResolver, "private_dns_mode")
+ val dnsProvider = Settings.Global.getString(contentResolver, "private_dns_specifier")
+
+ if (dnsMode.equals(DNS_MODE_OFF, ignoreCase = true)) {
+ if (sharedPreferences.autoMode == AUTO_MODE_OPTION_AUTO || sharedPreferences.autoMode == AUTO_MODE_OPTION_OFF_AUTO) {
+ changeDNSServer(DNS_MODE_AUTO, dnsProvider)
+ } else {
+ changeDNSServer(DNS_MODE_PRIVATE, dnsProvider)
+ }
+
+ } else if (dnsMode == null || dnsMode.equals(DNS_MODE_AUTO, ignoreCase = true)) {
+ changeDNSServer(DNS_MODE_PRIVATE, null)
+ } else if (dnsMode.equals(DNS_MODE_PRIVATE, ignoreCase = true)) {
+ scope.launch {
+ if (getNextAddress(dnsProvider) == null) {
+ if (sharedPreferences.autoMode == AUTO_MODE_OPTION_PRIVATE) {
+ changeDNSServer(DNS_MODE_PRIVATE, null)
+ } else {
+ if (sharedPreferences.autoMode == AUTO_MODE_OPTION_AUTO) {
+ changeDNSServer(DNS_MODE_AUTO, dnsProvider)
+ } else {
+ changeDNSServer(DNS_MODE_OFF, dnsProvider)
+ }
+ }
+ } else {
+ changeDNSServer(DNS_MODE_PRIVATE, dnsProvider)
+ }
+ }
+ }
+ }
+
+ /**
+ * Sets the state of the tile to the provided values
+ * @param mode dns mode
+ * @param dnsProvider dns provider
+ */
+ private fun changeDNSServer(mode: String, dnsProvider: String?) {
+ when (mode) {
+ DNS_MODE_OFF -> {
+ changeTileState(
+ qsTile,
+ Tile.STATE_INACTIVE,
+ getString(R.string.dns_off),
+ R.drawable.ic_off_black_24dp,
+ DNS_MODE_OFF,
+ null
+ )
+ }
+
+ DNS_MODE_AUTO -> {
+ changeTileState(
+ qsTile,
+ Tile.STATE_INACTIVE,
+ getString(R.string.dns_auto),
+ R.drawable.ic_auto_black_24dp,
+ DNS_MODE_AUTO,
+ dnsProvider
+ )
+ }
+
+ DNS_MODE_PRIVATE -> {
+ scope.launch {
+ val nextDnsServer = getNextAddress(dnsProvider)
+ if (nextDnsServer != null) {
+ changeTileState(
+ qsTile,
+ Tile.STATE_ACTIVE,
+ nextDnsServer.label.ifEmpty { nextDnsServer.server },
+ R.drawable.ic_private_black_24dp,
+ DNS_MODE_PRIVATE,
+ getNextAddress(dnsProvider)?.server
+ )
+ }
+ }
+ }
+ }
+ }
+
+ override fun onClick() {
+ super.onClick()
+ if (!checkForPermission(this)) {
+ return
+ }
+
+ // Require unlock to change mode according to user preference
+ val requireUnlock = sharedPreferences.requireUnlock
+ if (isLocked && requireUnlock) {
+ unlockAndRun(this::cycleState)
+ } else {
+ cycleState()
+ }
+
+
+ }
+
+ /**
+ * Refreshes the state of the tile
+ */
+ private fun refreshTile() {
+ val isPermissionGranted = checkForPermission(this)
+ val dnsMode = Settings.Global.getString(contentResolver, "private_dns_mode")
+ when (dnsMode?.lowercase()) {
+ DNS_MODE_OFF -> {
+ setTile(
+ qsTile,
+ if (!isPermissionGranted) Tile.STATE_UNAVAILABLE else Tile.STATE_INACTIVE,
+ getString(R.string.dns_off),
+ R.drawable.ic_off_black_24dp
+ )
+ }
+
+ DNS_MODE_AUTO -> {
+ setTile(
+ qsTile,
+ if (!isPermissionGranted) Tile.STATE_UNAVAILABLE else Tile.STATE_INACTIVE,
+ getString(R.string.dns_auto),
+ R.drawable.ic_auto_black_24dp
+ )
+ }
+
+ DNS_MODE_PRIVATE -> {
+ scope.launch {
+ val activeAddress =
+ Settings.Global.getString(contentResolver, "private_dns_specifier")
+ val dnsServer = repository.getFirstByServer(activeAddress)
+ setTile(
+ qsTile,
+ if (!isPermissionGranted) Tile.STATE_UNAVAILABLE else Tile.STATE_ACTIVE,
+ // display server address if either there is no label or the server is not known
+ dnsServer?.label?.ifBlank { activeAddress } ?: activeAddress,
+ R.drawable.ic_private_black_24dp
+ )
+ }
+ }
+
+ else -> {
+ setTile(
+ qsTile,
+ if (!isPermissionGranted) Tile.STATE_UNAVAILABLE else Tile.STATE_INACTIVE,
+ getString(R.string.dns_unknown),
+ R.drawable.ic_unknown_black_24dp
+ )
+ }
+ }
+ }
+
+ private val broadcastReceiver = object : BroadcastReceiver() {
+ override fun onReceive(context: Context, intent: Intent) {
+ refreshTile()
+ }
+ }
+
+ override fun onStartListening() {
+ super.onStartListening()
+
+ // Prevent some crashes
+ if (qsTile == null) {
+ Log.w(TAG, "onStartListening: qsTile is null")
+ return
+ }
+
+
+ // Receive broadcasts to update the tile when server is changed from the dialog
+ ContextCompat.registerReceiver(
+ this,
+ broadcastReceiver,
+ IntentFilter("refresh_tile"),
+ ContextCompat.RECEIVER_NOT_EXPORTED
+ )
+ isBroadcastReceiverRegistered = true
+ refreshTile()
+ }
+
+ override fun onStopListening() {
+ super.onStopListening()
+ if (isBroadcastReceiverRegistered) {
+ unregisterReceiver(broadcastReceiver)
+ isBroadcastReceiverRegistered = false
+ }
+
+ }
+
+ override fun onDestroy() {
+ super.onDestroy()
+ job.cancelChildren()
+ }
+
+ /**
+ * Updates tile to specified parameters
+ *
+ * @param tile tile to update
+ * @param state tile state
+ * @param label tile label
+ * @param icon tile icon
+ */
+ private fun setTile(tile: Tile, state: Int, label: String?, icon: Int) {
+ tile.state = state
+ tile.label = label
+ tile.icon = Icon.createWithResource(this, icon)
+ tile.updateTile()
+ }
+
+ /**
+ * Updates tile and system settings to specified parameters
+ *
+ * @param tile tile to update
+ * @param state tile state
+ * @param label tile label
+ * @param icon tile icon
+ * @param dnsMode system dns mode
+ * @param dnsProvider system dns provider
+ */
+ private fun changeTileState(
+ tile: Tile,
+ state: Int,
+ label: String?,
+ icon: Int,
+ dnsMode: String,
+ dnsProvider: String?
+ ) {
+ tile.label = label
+ tile.state = state
+ tile.icon = Icon.createWithResource(this, icon)
+ PrivateDNSUtils.setPrivateMode(contentResolver, dnsMode)
+ PrivateDNSUtils.setPrivateProvider(contentResolver, dnsProvider)
+ tile.updateTile()
+ }
+
+ /**
+ * Gets next dns address from the database,
+ * if current address is last or unknown returns null
+ *
+ * @param currentAddress currently set address
+ * @return next address
+ */
+ private suspend fun getNextAddress(currentAddress: String?): DnsServer? {
+ return if (currentAddress.isNullOrEmpty()) {
+ repository.getFirstEnabled()
+ } else {
+ repository.getNextByServer(currentAddress)
+ }
+ }
+
+ companion object {
+ private const val TAG = "DnsTileService"
+ }
+}
diff --git a/app/src/main/java/ru/karasevm/privatednstoggle/ui/AddServerDialogFragment.kt b/app/src/main/java/ru/karasevm/privatednstoggle/ui/AddServerDialogFragment.kt
new file mode 100644
index 0000000..f5440d9
--- /dev/null
+++ b/app/src/main/java/ru/karasevm/privatednstoggle/ui/AddServerDialogFragment.kt
@@ -0,0 +1,140 @@
+package ru.karasevm.privatednstoggle.ui
+
+import android.app.Dialog
+import android.content.Context
+import android.content.DialogInterface
+import android.os.Bundle
+import android.text.Editable
+import android.text.TextUtils
+import android.text.TextWatcher
+import androidx.appcompat.app.AlertDialog
+import androidx.fragment.app.DialogFragment
+import com.google.android.material.dialog.MaterialAlertDialogBuilder
+import com.google.common.net.InternetDomainName
+import ru.karasevm.privatednstoggle.R
+import ru.karasevm.privatednstoggle.databinding.DialogAddBinding
+import ru.karasevm.privatednstoggle.model.DnsServer
+
+
+class AddServerDialogFragment(
+ private val dnsServer: DnsServer?
+) : DialogFragment() {
+ // Use this instance of the interface to deliver action events
+ private lateinit var listener: NoticeDialogListener
+
+ private var _binding: DialogAddBinding? = null
+
+ // This property is only valid between onCreateView and
+ // onDestroyView.
+ private val binding get() = _binding!!
+
+ /* The activity that creates an instance of this dialog fragment must
+ * implement this interface in order to receive event callbacks.
+ * Each method passes the DialogFragment in case the host needs to query it. */
+ interface NoticeDialogListener {
+ fun onAddDialogPositiveClick(label: String?, server: String)
+ fun onUpdateDialogPositiveClick(id: Int, server: String, label: String?, enabled: Boolean)
+ fun onDeleteItemClicked(id: Int)
+ }
+
+ // Override the Fragment.onAttach() method to instantiate the NoticeDialogListener
+ override fun onAttach(context: Context) {
+ super.onAttach(context)
+ // Verify that the host activity implements the callback interface
+ try {
+ // Instantiate the NoticeDialogListener so we can send events to the host
+ listener = context as NoticeDialogListener
+ } catch (e: ClassCastException) {
+ // The activity doesn't implement the interface, throw exception
+ throw ClassCastException(
+ (context.toString() +
+ " must implement NoticeDialogListener")
+ )
+ }
+ }
+
+ override fun onCreateDialog(
+ savedInstanceState: Bundle?
+ ): Dialog {
+ return activity?.let {
+ val builder = MaterialAlertDialogBuilder(it)
+ // Get the layout inflater
+ val inflater = requireActivity().layoutInflater
+ _binding = DialogAddBinding.inflate(inflater)
+
+ val view = binding.root
+ // Inflate and set the layout for the dialog
+ // Pass null as the parent view because its going in the dialog layout
+ if (dnsServer != null) {
+ binding.editTextServerHint.setText(dnsServer.label)
+ binding.editTextServerAddr.setText(dnsServer.server)
+ binding.serverEnabledSwitch.visibility = android.view.View.VISIBLE
+ binding.serverEnabledSwitch.isChecked = dnsServer.enabled
+ builder.setTitle(R.string.edit_server).setView(view)
+ .setPositiveButton(
+ R.string.menu_save
+ ) { _, _ ->
+ listener.onUpdateDialogPositiveClick(
+ dnsServer.id,
+ binding.editTextServerAddr.text.toString().trim(),
+ binding.editTextServerHint.text.toString().trim(),
+ binding.serverEnabledSwitch.isChecked
+ )
+ }
+ .setNegativeButton(
+ R.string.cancel
+ ) { _, _ ->
+ dialog?.cancel()
+ }
+ .setNeutralButton(
+ R.string.delete
+ ) { _, _ ->
+ listener.onDeleteItemClicked(dnsServer.id)
+ }
+ } else {
+ builder.setTitle(R.string.add_server)
+ .setView(view)
+ // Add action buttons
+ .setPositiveButton(
+ R.string.menu_add
+ ) { _, _ ->
+ listener.onAddDialogPositiveClick(
+ binding.editTextServerHint.text.toString().trim(),
+ binding.editTextServerAddr.text.toString().trim()
+ )
+ }
+ .setNegativeButton(
+ R.string.cancel
+ ) { _, _ ->
+ dialog?.cancel()
+ }
+ }
+ builder.create()
+ } ?: throw IllegalStateException("Activity cannot be null")
+ }
+
+ override fun onStart() {
+ super.onStart()
+ val button = ((dialog) as AlertDialog).getButton(DialogInterface.BUTTON_POSITIVE)
+ binding.editTextServerAddr.addTextChangedListener(object : TextWatcher {
+ override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {}
+
+ override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {}
+
+ override fun afterTextChanged(s: Editable?) {
+ val server = binding.editTextServerAddr.text.toString().trim()
+ if (TextUtils.isEmpty(server) || !isValidServer(server)) {
+ button.isEnabled = false
+ } else {
+ binding.editTextServerAddr.error = null
+ button.isEnabled = true
+ }
+ }
+ })
+ }
+
+ private fun isValidServer(str: String): Boolean {
+ return InternetDomainName.isValid(str)
+ }
+
+}
\ No newline at end of file
diff --git a/app/src/main/java/ru/karasevm/privatednstoggle/ui/DNSServerDialogFragment.kt b/app/src/main/java/ru/karasevm/privatednstoggle/ui/DNSServerDialogFragment.kt
new file mode 100644
index 0000000..b2fa284
--- /dev/null
+++ b/app/src/main/java/ru/karasevm/privatednstoggle/ui/DNSServerDialogFragment.kt
@@ -0,0 +1,140 @@
+package ru.karasevm.privatednstoggle.ui
+
+import android.app.Dialog
+import android.content.Intent
+import android.os.Bundle
+import android.widget.Toast
+import androidx.fragment.app.DialogFragment
+import androidx.fragment.app.viewModels
+import androidx.lifecycle.lifecycleScope
+import androidx.recyclerview.widget.LinearLayoutManager
+import com.google.android.material.dialog.MaterialAlertDialogBuilder
+import kotlinx.coroutines.launch
+import ru.karasevm.privatednstoggle.PrivateDNSApp
+import ru.karasevm.privatednstoggle.R
+import ru.karasevm.privatednstoggle.data.DnsServerViewModel
+import ru.karasevm.privatednstoggle.data.DnsServerViewModelFactory
+import ru.karasevm.privatednstoggle.databinding.SheetDnsSelectorBinding
+import ru.karasevm.privatednstoggle.model.DnsServer
+import ru.karasevm.privatednstoggle.util.PrivateDNSUtils
+import ru.karasevm.privatednstoggle.util.PrivateDNSUtils.checkForPermission
+
+class DNSServerDialogFragment : DialogFragment() {
+
+ private var _binding: SheetDnsSelectorBinding? = null
+ private val binding get() = _binding!!
+
+ private lateinit var linearLayoutManager: LinearLayoutManager
+ private lateinit var adapter: ServerListRecyclerAdapter
+ private var servers: MutableList = mutableListOf()
+ private val dnsServerViewModel: DnsServerViewModel by viewModels { DnsServerViewModelFactory((requireActivity().application as PrivateDNSApp).repository) }
+ private val contentResolver by lazy { requireActivity().contentResolver }
+
+ override fun onCreateDialog(
+ savedInstanceState: Bundle?
+ ): Dialog {
+ return activity?.let {
+ val startIntent = Intent(context, MainActivity::class.java)
+
+ val builder = MaterialAlertDialogBuilder(it)
+ val inflater = requireActivity().layoutInflater
+ _binding = SheetDnsSelectorBinding.inflate(inflater)
+ linearLayoutManager = LinearLayoutManager(context)
+ binding.recyclerView.layoutManager = linearLayoutManager
+
+ adapter = ServerListRecyclerAdapter(false)
+ binding.recyclerView.adapter = adapter
+ lifecycleScope.launch {
+ dnsServerViewModel.getAll().collect { s ->
+ servers = s.toMutableList()
+ if (servers.isEmpty()) {
+ servers.add(DnsServer(0, "dns.google"))
+ }
+ servers.add(0, DnsServer(-1, resources.getString(R.string.dns_auto)))
+ servers.add(0, DnsServer(-2, resources.getString(R.string.dns_off)))
+ adapter.submitList(servers)
+ }
+ }
+ builder.setTitle(R.string.select_server)
+ .setView(binding.root)
+ .setPositiveButton(
+ R.string.done
+ ) { _, _ ->
+ dialog?.dismiss()
+ }
+ .setNeutralButton(R.string.open_app) { _, _ -> context?.startActivity(startIntent) }
+ builder.create()
+ } ?: throw IllegalStateException("Activity cannot be null")
+ }
+
+ override fun onStart() {
+ super.onStart()
+ if (!checkForPermission(requireContext())) {
+ Toast.makeText(
+ context, R.string.permission_missing, Toast.LENGTH_SHORT
+ ).show()
+ dialog!!.dismiss()
+ }
+ adapter.onItemClick = { id ->
+ when (id) {
+ OFF_ID -> {
+ PrivateDNSUtils.setPrivateMode(
+ contentResolver,
+ PrivateDNSUtils.DNS_MODE_OFF
+ )
+ PrivateDNSUtils.setPrivateProvider(
+ contentResolver,
+ null)
+ Toast.makeText(context, R.string.set_to_off_toast, Toast.LENGTH_SHORT).show()
+ }
+
+ AUTO_ID -> {
+ PrivateDNSUtils.setPrivateMode(
+ contentResolver,
+ PrivateDNSUtils.DNS_MODE_AUTO
+ )
+ PrivateDNSUtils.setPrivateProvider(
+ contentResolver,
+ null)
+ Toast.makeText(context, R.string.set_to_auto_toast, Toast.LENGTH_SHORT).show()
+ }
+
+ else -> {
+ lifecycleScope.launch {
+ val server = servers.find { server -> server.id == id }
+ PrivateDNSUtils.setPrivateMode(
+ contentResolver,
+ PrivateDNSUtils.DNS_MODE_PRIVATE
+ )
+ PrivateDNSUtils.setPrivateProvider(
+ contentResolver,
+ server?.server
+ )
+ Toast.makeText(
+ context,
+ getString(
+ R.string.set_to_provider_toast,
+ server?.label?.ifEmpty { server.server }
+ ),
+ Toast.LENGTH_SHORT
+ ).show()
+ }
+ }
+ }
+ dialog?.dismiss()
+ requireContext().sendBroadcast(Intent("refresh_tile").setPackage(requireContext().packageName))
+ }
+
+ }
+
+ override fun onDestroy() {
+ super.onDestroy()
+ activity?.finish()
+ }
+
+ companion object {
+ const val TAG = "DNSServerDialogFragment"
+ private const val AUTO_ID = -1
+ private const val OFF_ID = -2
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/ru/karasevm/privatednstoggle/DeleteServerDialogFragment.kt b/app/src/main/java/ru/karasevm/privatednstoggle/ui/DeleteServerDialogFragment.kt
similarity index 74%
rename from app/src/main/java/ru/karasevm/privatednstoggle/DeleteServerDialogFragment.kt
rename to app/src/main/java/ru/karasevm/privatednstoggle/ui/DeleteServerDialogFragment.kt
index 0488650..a6a2d98 100644
--- a/app/src/main/java/ru/karasevm/privatednstoggle/DeleteServerDialogFragment.kt
+++ b/app/src/main/java/ru/karasevm/privatednstoggle/ui/DeleteServerDialogFragment.kt
@@ -1,13 +1,14 @@
-package ru.karasevm.privatednstoggle
+package ru.karasevm.privatednstoggle.ui
import android.app.Dialog
import android.content.Context
import android.os.Bundle
import androidx.fragment.app.DialogFragment
import com.google.android.material.dialog.MaterialAlertDialogBuilder
+import ru.karasevm.privatednstoggle.R
-class DeleteServerDialogFragment(private val position: Int): DialogFragment() {
+class DeleteServerDialogFragment(private val id: Int) : DialogFragment() {
// Use this instance of the interface to deliver action events
private lateinit var listener: NoticeDialogListener
@@ -15,7 +16,7 @@ class DeleteServerDialogFragment(private val position: Int): DialogFragment() {
* implement this interface in order to receive event callbacks.
* Each method passes the DialogFragment in case the host needs to query it. */
interface NoticeDialogListener {
- fun onDialogPositiveClick(dialog: DialogFragment, position: Int)
+ fun onDeleteDialogPositiveClick(id: Int)
}
// Override the Fragment.onAttach() method to instantiate the NoticeDialogListener
@@ -27,21 +28,26 @@ class DeleteServerDialogFragment(private val position: Int): DialogFragment() {
listener = context as NoticeDialogListener
} catch (e: ClassCastException) {
// The activity doesn't implement the interface, throw exception
- throw ClassCastException((context.toString() +
- " must implement NoticeDialogListener"))
+ throw ClassCastException(
+ (context.toString() +
+ " must implement NoticeDialogListener")
+ )
}
}
+
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
return activity?.let {
val builder = MaterialAlertDialogBuilder(it)
builder.setTitle(R.string.delete_question)
.setMessage(R.string.delete_message)
- .setPositiveButton(R.string.delete
+ .setPositiveButton(
+ R.string.delete
) { _, _ ->
- listener.onDialogPositiveClick(this, position)
+ listener.onDeleteDialogPositiveClick(id)
}
- .setNegativeButton(R.string.cancel
+ .setNegativeButton(
+ R.string.cancel
) { _, _ ->
dialog?.cancel()
}
diff --git a/app/src/main/java/ru/karasevm/privatednstoggle/ui/MainActivity.kt b/app/src/main/java/ru/karasevm/privatednstoggle/ui/MainActivity.kt
new file mode 100644
index 0000000..26695a3
--- /dev/null
+++ b/app/src/main/java/ru/karasevm/privatednstoggle/ui/MainActivity.kt
@@ -0,0 +1,480 @@
+package ru.karasevm.privatednstoggle.ui
+
+import android.Manifest
+import android.content.ClipData
+import android.content.ClipDescription.MIMETYPE_TEXT_PLAIN
+import android.content.ClipboardManager
+import android.content.Intent
+import android.content.SharedPreferences
+import android.content.pm.PackageManager
+import android.graphics.Color
+import android.net.Uri
+import android.os.Build
+import android.os.Bundle
+import android.util.Log
+import android.view.Menu
+import android.view.View
+import android.widget.Toast
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.activity.viewModels
+import androidx.appcompat.app.AppCompatActivity
+import androidx.core.app.ShareCompat
+import androidx.lifecycle.viewModelScope
+import androidx.recyclerview.widget.ItemTouchHelper
+import androidx.recyclerview.widget.ItemTouchHelper.DOWN
+import androidx.recyclerview.widget.ItemTouchHelper.UP
+import androidx.recyclerview.widget.LinearLayoutManager
+import androidx.recyclerview.widget.RecyclerView
+import kotlinx.coroutines.launch
+import kotlinx.serialization.encodeToString
+import kotlinx.serialization.json.Json
+import rikka.shizuku.Shizuku
+import rikka.shizuku.ShizukuProvider
+import ru.karasevm.privatednstoggle.PrivateDNSApp
+import ru.karasevm.privatednstoggle.R
+import ru.karasevm.privatednstoggle.data.DnsServerViewModel
+import ru.karasevm.privatednstoggle.data.DnsServerViewModelFactory
+import ru.karasevm.privatednstoggle.databinding.ActivityMainBinding
+import ru.karasevm.privatednstoggle.model.DnsServer
+import ru.karasevm.privatednstoggle.util.BackupUtils
+import ru.karasevm.privatednstoggle.util.PreferenceHelper
+import ru.karasevm.privatednstoggle.util.PreferenceHelper.dns_servers
+import ru.karasevm.privatednstoggle.util.ShizukuUtil.grantPermissionWithShizuku
+
+
+class MainActivity : AppCompatActivity(), AddServerDialogFragment.NoticeDialogListener,
+ DeleteServerDialogFragment.NoticeDialogListener, Shizuku.OnRequestPermissionResultListener {
+
+ private lateinit var linearLayoutManager: LinearLayoutManager
+ private lateinit var binding: ActivityMainBinding
+ private lateinit var sharedPrefs: SharedPreferences
+ private lateinit var adapter: ServerListRecyclerAdapter
+ private lateinit var clipboard: ClipboardManager
+ private val dnsServerViewModel: DnsServerViewModel by viewModels { DnsServerViewModelFactory((application as PrivateDNSApp).repository) }
+
+ private val itemTouchHelper by lazy {
+ val simpleItemTouchCallback =
+ object : ItemTouchHelper.SimpleCallback(UP or DOWN, 0) {
+ var dragFrom = -1
+ var dragTo = -1
+
+ override fun onMove(
+ recyclerView: RecyclerView,
+ viewHolder: RecyclerView.ViewHolder,
+ target: RecyclerView.ViewHolder
+ ): Boolean {
+ if (dragFrom == viewHolder.bindingAdapterPosition && dragTo == target.bindingAdapterPosition) {
+ return true
+ }
+ // store the drag position
+ if (dragFrom == -1) dragFrom = viewHolder.bindingAdapterPosition
+ dragTo = target.bindingAdapterPosition
+ adapter.onItemMove(
+ viewHolder.bindingAdapterPosition,
+ target.bindingAdapterPosition
+ )
+ return true
+ }
+
+ override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) {}
+
+ override fun onSelectedChanged(
+ viewHolder: RecyclerView.ViewHolder?, actionState: Int
+ ) {
+ super.onSelectedChanged(viewHolder, actionState)
+ if (actionState == ItemTouchHelper.ACTION_STATE_DRAG) {
+ viewHolder?.itemView?.apply {
+ // Example: Elevate the view
+ elevation = 8f
+ alpha = 0.5f
+ setBackgroundColor(Color.GRAY)
+ }
+ }
+ }
+
+ override fun clearView(
+ recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder
+ ) {
+ super.clearView(recyclerView, viewHolder)
+ viewHolder.itemView.apply {
+ // Reset the appearance
+ elevation = 0f
+ alpha = 1.0f
+ setBackgroundColor(Color.TRANSPARENT)
+ }
+ // commit the change to the db
+ dnsServerViewModel.move(
+ dragFrom,
+ dragTo,
+ (viewHolder as ServerListRecyclerAdapter.DnsServerViewHolder).id
+ )
+ dragTo = -1
+ dragFrom = -1
+ }
+ }
+ ItemTouchHelper(simpleItemTouchCallback)
+ }
+
+ private fun importSettings(json: String) {
+ runCatching {
+ val data: BackupUtils.Backup = Json.decodeFromString(json)
+ BackupUtils.import(data, dnsServerViewModel, sharedPrefs)
+ }.onSuccess {
+ Toast.makeText(
+ this, getString(R.string.import_success), Toast.LENGTH_SHORT
+ ).show()
+ }.onFailure { exception ->
+ runCatching {
+ Log.e("IMPORT", "Malformed json, falling back to legacy", exception)
+ val data = Json.decodeFromString(json)
+ BackupUtils.importLegacy(data, dnsServerViewModel, sharedPrefs)
+ }.onSuccess {
+ Toast.makeText(
+ this, getString(R.string.import_success), Toast.LENGTH_SHORT
+ ).show()
+ }.onFailure { exception ->
+ Log.e("IMPORT", "Import failed", exception)
+ Toast.makeText(
+ this, getString(R.string.import_failure), Toast.LENGTH_SHORT
+ ).show()
+ }
+ }
+ }
+
+ /**
+ * Migrate the SharedPreferences server list to Room
+ */
+ private fun migrateServerList() {
+ dnsServerViewModel.viewModelScope.launch {
+ if (sharedPrefs.dns_servers.isNotEmpty() && sharedPrefs.dns_servers[0] != "") {
+ Log.i(
+ "migrate",
+ "existing sharedPrefs list: ${sharedPrefs.dns_servers} ${sharedPrefs.dns_servers.size}"
+ )
+ sharedPrefs.dns_servers.forEach { server ->
+ val parts = server.split(" : ").toMutableList()
+ if (parts.size != 2) parts.add(0, "")
+ Log.i("migrate", "migrating: $server -> $parts")
+ dnsServerViewModel.insert(DnsServer(0, parts[1], parts[0]))
+ }
+ sharedPrefs.dns_servers = emptyList().toMutableList()
+ }
+ }
+ }
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+
+ Shizuku.addRequestPermissionResultListener(this::onRequestPermissionResult)
+
+ binding = ActivityMainBinding.inflate(layoutInflater)
+ val view = binding.root
+ setContentView(view)
+
+ linearLayoutManager = LinearLayoutManager(this)
+ binding.recyclerView.layoutManager = linearLayoutManager
+
+ sharedPrefs = PreferenceHelper.defaultPreference(this)
+ clipboard = getSystemService(CLIPBOARD_SERVICE) as ClipboardManager
+
+ migrateServerList()
+
+ adapter = ServerListRecyclerAdapter(true)
+ binding.recyclerView.adapter = adapter
+
+ dnsServerViewModel.allServers.observe(this) { servers ->
+ adapter.submitList(servers)
+ if (servers.isEmpty()) {
+ binding.emptyView.visibility = View.VISIBLE
+ binding.emptyViewHint.visibility = View.VISIBLE
+ } else {
+ binding.emptyView.visibility = View.GONE
+ binding.emptyViewHint.visibility = View.GONE
+ }
+ }
+ adapter.onItemClick = { id ->
+ dnsServerViewModel.viewModelScope.launch {
+ val server = dnsServerViewModel.getById(id)
+ if (server != null) {
+ val newFragment =
+ AddServerDialogFragment(server)
+ newFragment.show(supportFragmentManager, "edit_server")
+ }
+ }
+ }
+ adapter.onDragStart = { viewHolder ->
+ itemTouchHelper.startDrag(viewHolder)
+ }
+ binding.floatingActionButton.setOnClickListener {
+ val newFragment = AddServerDialogFragment(null)
+ newFragment.show(supportFragmentManager, "add_server")
+ }
+ binding.recyclerView.adapter = adapter
+ itemTouchHelper.attachToRecyclerView(binding.recyclerView)
+
+ binding.topAppBar.setOnMenuItemClickListener { item ->
+ when (item.itemId) {
+ R.id.privacy_policy -> {
+ val browserIntent = Intent(
+ Intent.ACTION_VIEW,
+ Uri.parse("https://karasevm.github.io/PrivateDNSAndroid/privacy_policy")
+ )
+ startActivity(browserIntent)
+ true
+ }
+
+ R.id.export_settings_clipboard -> {
+ dnsServerViewModel.viewModelScope.launch {
+ val data = BackupUtils.export(dnsServerViewModel, sharedPrefs)
+ val jsonData = Json.encodeToString(data)
+ clipboard.setPrimaryClip(ClipData.newPlainText("", jsonData))
+ // Only show a toast for Android 12 and lower.
+ if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.S_V2) Toast.makeText(
+ applicationContext, getString(R.string.copy_success), Toast.LENGTH_SHORT
+ ).show()
+ }
+ true
+ }
+
+ R.id.export_settings_share -> {
+ val activityContext = this
+ dnsServerViewModel.viewModelScope.launch {
+ val data = BackupUtils.export(dnsServerViewModel, sharedPrefs)
+ val jsonData = Json.encodeToString(data)
+ ShareCompat.IntentBuilder(activityContext).setText(jsonData)
+ .setType("text/plain")
+ .startChooser()
+ }
+ true
+ }
+
+ R.id.export_settings_file -> {
+
+ dnsServerViewModel.viewModelScope.launch {
+ val intent = Intent(Intent.ACTION_CREATE_DOCUMENT).apply {
+ addCategory(Intent.CATEGORY_OPENABLE)
+ type = "text/plain"
+ putExtra(Intent.EXTRA_TITLE, "private-dns-export")
+ }
+ saveResultLauncher.launch(intent)
+ }
+ true
+ }
+
+ R.id.import_settings_file -> {
+ val intent = Intent(Intent.ACTION_OPEN_DOCUMENT).apply {
+ addCategory(Intent.CATEGORY_OPENABLE)
+ type = "text/plain"
+ }
+ importResultLauncher.launch(intent)
+ true
+ }
+
+ R.id.import_settings_clipboard -> {
+ val clipData = clipboard.primaryClip?.getItemAt(0)
+ val textData = clipData?.text
+
+ if (textData != null) {
+ importSettings(textData.toString())
+ }
+ true
+ }
+
+ R.id.options -> {
+ val newFragment = OptionsDialogFragment()
+ newFragment.show(supportFragmentManager, "options")
+ true
+ }
+
+ else -> true
+ }
+ }
+ }
+
+ private var saveResultLauncher =
+ registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
+ if (result.resultCode == RESULT_OK) {
+ val data: Intent? = result.data
+ data?.data?.also { uri ->
+ val jsonData = Json.encodeToString(BackupUtils.export(dnsServerViewModel, sharedPrefs))
+ val contentResolver = applicationContext.contentResolver
+ runCatching {
+ contentResolver.openOutputStream(uri)?.use { outputStream ->
+ outputStream.write(jsonData.toByteArray())
+ }
+ }.onFailure { exception ->
+ Log.e("EXPORT", "Export failed", exception)
+ Toast.makeText(
+ this, getString(R.string.export_failure), Toast.LENGTH_SHORT
+ ).show()
+ }.onSuccess {
+ Toast.makeText(
+ this, getString(R.string.export_success), Toast.LENGTH_SHORT
+ ).show()
+ }
+ }
+ }
+ }
+
+ private var importResultLauncher =
+ registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
+ if (result.resultCode == RESULT_OK) {
+ val data: Intent? = result.data
+ data?.data?.also { uri ->
+ val contentResolver = applicationContext.contentResolver
+ contentResolver.openInputStream(uri)?.use { inputStream ->
+ val jsonData = inputStream.bufferedReader().use { it.readText() }
+ importSettings(jsonData)
+ }
+ }
+ }
+ }
+
+ override fun onCreateOptionsMenu(menu: Menu?): Boolean {
+ menuInflater.inflate(R.menu.menu_main, menu)
+ return true
+ }
+
+ override fun onResume() {
+ super.onResume()
+ // Check if WRITE_SECURE_SETTINGS is granted
+ if (checkSelfPermission(Manifest.permission.WRITE_SECURE_SETTINGS) != PackageManager.PERMISSION_GRANTED) {
+ // Check if Shizuku is available
+ if (Shizuku.pingBinder()) {
+ // check if permission is granted already
+ val isGranted = if (Shizuku.isPreV11() || Shizuku.getVersion() < 11) {
+ checkSelfPermission(ShizukuProvider.PERMISSION) == PackageManager.PERMISSION_GRANTED
+ } else {
+ Shizuku.checkSelfPermission() == PackageManager.PERMISSION_GRANTED
+ }
+ // request permission if not granted
+ if (!isGranted && !Shizuku.shouldShowRequestPermissionRationale()) {
+ if (Shizuku.isPreV11() || Shizuku.getVersion() < 11) {
+ requestPermissions(arrayOf(ShizukuProvider.PERMISSION), 1)
+ } else {
+ Shizuku.requestPermission(1)
+ }
+ } else {
+ grantPermission()
+ }
+ } else {
+ if (checkSelfPermission(Manifest.permission.WRITE_SECURE_SETTINGS) != PackageManager.PERMISSION_GRANTED) {
+ val browserIntent = Intent(
+ Intent.ACTION_VIEW,
+ Uri.parse("https://karasevm.github.io/PrivateDNSAndroid/")
+ )
+ Toast.makeText(
+ this, R.string.shizuku_failure_toast, Toast.LENGTH_SHORT
+ ).show()
+ startActivity(browserIntent)
+ finish()
+ }
+ }
+ }
+ }
+
+ override fun onWindowFocusChanged(hasFocus: Boolean) {
+ super.onWindowFocusChanged(hasFocus)
+ if (!hasFocus) {
+ // Gets the ID of the "paste" menu item.
+ val pasteItem = binding.topAppBar.menu.findItem(R.id.import_settings_clipboard)
+
+ // If the clipboard doesn't contain data, disable the paste menu item.
+ // If it does contain data, decide whether you can handle the data.
+ pasteItem.isEnabled = when {
+ !clipboard.hasPrimaryClip() -> false
+ !(clipboard.primaryClipDescription?.hasMimeType(MIMETYPE_TEXT_PLAIN))!! -> false
+ else -> true
+
+ }
+ }
+ }
+
+ override fun onDestroy() {
+ super.onDestroy()
+ Shizuku.removeRequestPermissionResultListener(this::onRequestPermissionResult)
+ }
+
+ /**
+ * Show the dialog for deleting the server
+ * @param id The server id
+ */
+ override fun onDeleteItemClicked(id: Int) {
+ val newFragment = DeleteServerDialogFragment(id)
+ newFragment.show(supportFragmentManager, "delete_server")
+ }
+
+ /**
+ * Callback for adding the server
+ * @param label The label
+ * @param server The server
+ */
+ override fun onAddDialogPositiveClick(label: String?, server: String) {
+ if (server.isEmpty()) {
+ Toast.makeText(this, R.string.server_length_error, Toast.LENGTH_SHORT).show()
+ return
+ }
+
+ if (label.isNullOrEmpty()) {
+ dnsServerViewModel.insert(DnsServer(0, server))
+ } else {
+ dnsServerViewModel.insert(DnsServer(0, server, label))
+ }
+ }
+
+ /**
+ * Callback for deleting the server
+ * @param id The server id
+ */
+ override fun onDeleteDialogPositiveClick(id: Int) {
+ dnsServerViewModel.delete(id)
+ }
+
+ /**
+ * Callback for updating the server
+ * @param label New label
+ * @param server New server address
+ * @param id The server id
+ */
+ override fun onUpdateDialogPositiveClick(
+ id: Int,
+ server: String,
+ label: String?,
+ enabled: Boolean
+ ) {
+ if (server.isEmpty()) {
+ Toast.makeText(this, R.string.server_length_error, Toast.LENGTH_SHORT).show()
+ return
+ }
+ dnsServerViewModel.update(id, server, label, null, enabled)
+ }
+
+ private fun grantPermission() {
+ if (grantPermissionWithShizuku(this)) {
+ Toast.makeText(
+ this, R.string.shizuku_success_toast, Toast.LENGTH_SHORT
+ ).show()
+ } else {
+ Toast.makeText(
+ this, R.string.shizuku_failure_toast, Toast.LENGTH_SHORT
+ ).show()
+ val browserIntent = Intent(
+ Intent.ACTION_VIEW, Uri.parse("https://karasevm.github.io/PrivateDNSAndroid/")
+ )
+ startActivity(browserIntent)
+ finish()
+ }
+ }
+
+ override fun onRequestPermissionResult(requestCode: Int, grantResult: Int) {
+ val isGranted = grantResult == PackageManager.PERMISSION_GRANTED
+
+ if (!isGranted && checkSelfPermission(Manifest.permission.WRITE_SECURE_SETTINGS) != PackageManager.PERMISSION_GRANTED) {
+ val browserIntent = Intent(
+ Intent.ACTION_VIEW, Uri.parse("https://karasevm.github.io/PrivateDNSAndroid/")
+ )
+ startActivity(browserIntent)
+ finish()
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/ru/karasevm/privatednstoggle/ui/OptionsDialogFragment.kt b/app/src/main/java/ru/karasevm/privatednstoggle/ui/OptionsDialogFragment.kt
new file mode 100644
index 0000000..2ca7b14
--- /dev/null
+++ b/app/src/main/java/ru/karasevm/privatednstoggle/ui/OptionsDialogFragment.kt
@@ -0,0 +1,63 @@
+package ru.karasevm.privatednstoggle.ui
+
+import android.app.Dialog
+import android.os.Bundle
+import androidx.fragment.app.DialogFragment
+import com.google.android.material.dialog.MaterialAlertDialogBuilder
+import ru.karasevm.privatednstoggle.R
+import ru.karasevm.privatednstoggle.databinding.DialogOptionsBinding
+import ru.karasevm.privatednstoggle.util.PreferenceHelper
+import ru.karasevm.privatednstoggle.util.PreferenceHelper.autoMode
+import ru.karasevm.privatednstoggle.util.PreferenceHelper.requireUnlock
+import ru.karasevm.privatednstoggle.util.PrivateDNSUtils
+
+class OptionsDialogFragment : DialogFragment() {
+ private var _binding: DialogOptionsBinding? = null
+ private val binding get() = _binding!!
+ private val sharedPreferences by lazy { PreferenceHelper.defaultPreference(requireContext()) }
+
+ override fun onCreateDialog(
+ savedInstanceState: Bundle?
+ ): Dialog {
+ return activity?.let {
+ val builder = MaterialAlertDialogBuilder(it)
+ val inflater = requireActivity().layoutInflater
+ _binding = DialogOptionsBinding.inflate(inflater)
+
+ val view = binding.root
+ builder.setTitle(R.string.options)
+ .setView(view)
+ .setPositiveButton(R.string.ok, null)
+ builder.create()
+
+ } ?: throw IllegalStateException("Activity cannot be null")
+ }
+
+ override fun onStart() {
+ super.onStart()
+ val autoModeOption = sharedPreferences.autoMode
+ when (autoModeOption) {
+ PrivateDNSUtils.AUTO_MODE_OPTION_OFF -> binding.autoOptionRadioGroup.check(R.id.autoOptionOff)
+ PrivateDNSUtils.AUTO_MODE_OPTION_AUTO -> binding.autoOptionRadioGroup.check(R.id.autoOptionAuto)
+ PrivateDNSUtils.AUTO_MODE_OPTION_OFF_AUTO -> binding.autoOptionRadioGroup.check(R.id.autoOptionOffAuto)
+ PrivateDNSUtils.AUTO_MODE_OPTION_PRIVATE -> binding.autoOptionRadioGroup.check(R.id.autoOptionPrivate)
+ }
+ binding.autoOptionRadioGroup.setOnCheckedChangeListener { _, checkedId ->
+ when (checkedId) {
+ R.id.autoOptionOff -> sharedPreferences.autoMode = PrivateDNSUtils.AUTO_MODE_OPTION_OFF
+ R.id.autoOptionAuto -> sharedPreferences.autoMode = PrivateDNSUtils.AUTO_MODE_OPTION_AUTO
+ R.id.autoOptionOffAuto -> sharedPreferences.autoMode =
+ PrivateDNSUtils.AUTO_MODE_OPTION_OFF_AUTO
+
+ R.id.autoOptionPrivate -> sharedPreferences.autoMode =
+ PrivateDNSUtils.AUTO_MODE_OPTION_PRIVATE
+ }
+ }
+
+ val requireUnlock = sharedPreferences.requireUnlock
+ binding.requireUnlockSwitch.isChecked = requireUnlock
+ binding.requireUnlockSwitch.setOnCheckedChangeListener { _, isChecked ->
+ sharedPreferences.requireUnlock = isChecked
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/ru/karasevm/privatednstoggle/ui/ServerListRecyclerAdapter.kt b/app/src/main/java/ru/karasevm/privatednstoggle/ui/ServerListRecyclerAdapter.kt
new file mode 100644
index 0000000..fec1eb0
--- /dev/null
+++ b/app/src/main/java/ru/karasevm/privatednstoggle/ui/ServerListRecyclerAdapter.kt
@@ -0,0 +1,124 @@
+package ru.karasevm.privatednstoggle.ui
+
+import android.annotation.SuppressLint
+import android.view.LayoutInflater
+import android.view.MotionEvent
+import android.view.View
+import android.view.ViewGroup
+import android.widget.ImageView
+import android.widget.TextView
+import androidx.recyclerview.widget.DiffUtil
+import androidx.recyclerview.widget.RecyclerView
+import ru.karasevm.privatednstoggle.R
+import ru.karasevm.privatednstoggle.model.DnsServer
+
+
+class ServerListRecyclerAdapter(private val showDragHandle: Boolean) :
+ RecyclerView.Adapter() {
+
+
+ override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): DnsServerViewHolder {
+ val view =
+ LayoutInflater.from(parent.context).inflate(R.layout.recyclerview_row, parent, false)
+ val vh = DnsServerViewHolder(view)
+ return vh
+ }
+
+ override fun getItemCount(): Int {
+ return items.size
+ }
+
+ var onItemClick: ((Int) -> Unit)? = null
+ var onDragStart: ((DnsServerViewHolder) -> Unit)? = null
+ private var items: MutableList = mutableListOf()
+
+ @SuppressLint("ClickableViewAccessibility")
+ override fun onBindViewHolder(holder: DnsServerViewHolder, position: Int) {
+ val item = items[position]
+ if (item.label.isNotEmpty()) {
+ holder.labelTextView.text = item.label
+ holder.labelTextView.visibility = View.VISIBLE
+ } else {
+ holder.labelTextView.visibility = View.GONE
+ }
+ holder.serverTextView.text = item.server
+ holder.id = item.id
+ if (item.enabled) {
+ holder.labelTextView.alpha = 1f
+ holder.serverTextView.alpha = 1f
+ } else {
+ holder.labelTextView.alpha = 0.5f
+ holder.serverTextView.alpha = 0.5f
+ }
+ if (showDragHandle) {
+ holder.dragHandle.visibility = View.VISIBLE
+ holder.dragHandle.setOnTouchListener { _, event ->
+ if (event.actionMasked == MotionEvent.ACTION_DOWN) {
+ onDragStart?.invoke(holder)
+ }
+ return@setOnTouchListener true
+ }
+ }
+ }
+
+ /**
+ * Update server position in memory
+ * @param fromPosition old position
+ * @param toPosition new position
+ */
+ fun onItemMove(fromPosition: Int, toPosition: Int) {
+ items.add(toPosition, items.removeAt(fromPosition))
+ notifyItemMoved(fromPosition, toPosition)
+ }
+
+ class DiffCallback(
+ private val oldList: List, private var newList: List
+ ) : DiffUtil.Callback() {
+
+ override fun getOldListSize(): Int {
+ return oldList.size
+ }
+
+ override fun getNewListSize(): Int {
+ return newList.size
+ }
+
+ override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean {
+ val oldItem = oldList[oldItemPosition]
+ val newItem = newList[newItemPosition]
+ return oldItem.id == newItem.id
+ }
+
+ override fun areContentsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean {
+ val oldItem = oldList[oldItemPosition]
+ val newItem = newList[newItemPosition]
+ return oldItem.server == newItem.server && oldItem.label == newItem.label && oldItem.enabled == newItem.enabled
+ }
+ }
+
+ /**
+ * Submit list to adapter
+ * @param list list to submit
+ */
+ fun submitList(list: List) {
+ val diffCallback = DiffCallback(items, list)
+ val diffResult = DiffUtil.calculateDiff(diffCallback)
+ items.clear()
+ items.addAll(list)
+ diffResult.dispatchUpdatesTo(this)
+ }
+
+ inner class DnsServerViewHolder(view: View) : RecyclerView.ViewHolder(view) {
+ val labelTextView: TextView = view.findViewById(R.id.labelTextView)
+ val serverTextView: TextView = view.findViewById(R.id.textView)
+ val dragHandle: ImageView = itemView.findViewById(R.id.dragHandle)
+ var id = 0
+
+ init {
+ view.setOnClickListener {
+ onItemClick?.invoke(id)
+ }
+ }
+ }
+
+}
diff --git a/app/src/main/java/ru/karasevm/privatednstoggle/ui/SettingsDialogActivity.kt b/app/src/main/java/ru/karasevm/privatednstoggle/ui/SettingsDialogActivity.kt
new file mode 100644
index 0000000..4b15e05
--- /dev/null
+++ b/app/src/main/java/ru/karasevm/privatednstoggle/ui/SettingsDialogActivity.kt
@@ -0,0 +1,12 @@
+package ru.karasevm.privatednstoggle.ui
+
+import android.os.Bundle
+import androidx.appcompat.app.AppCompatActivity
+
+class SettingsDialogActivity : AppCompatActivity() {
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ val newFragment = DNSServerDialogFragment()
+ newFragment.show(supportFragmentManager, DNSServerDialogFragment.TAG)
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/ru/karasevm/privatednstoggle/util/BackupUtils.kt b/app/src/main/java/ru/karasevm/privatednstoggle/util/BackupUtils.kt
new file mode 100644
index 0000000..304f1db
--- /dev/null
+++ b/app/src/main/java/ru/karasevm/privatednstoggle/util/BackupUtils.kt
@@ -0,0 +1,79 @@
+package ru.karasevm.privatednstoggle.util
+
+import android.content.SharedPreferences
+import kotlinx.serialization.SerialName
+import kotlinx.serialization.Serializable
+import ru.karasevm.privatednstoggle.data.DnsServerViewModel
+import ru.karasevm.privatednstoggle.model.DnsServer
+import ru.karasevm.privatednstoggle.util.PreferenceHelper.autoMode
+import ru.karasevm.privatednstoggle.util.PreferenceHelper.requireUnlock
+
+object BackupUtils {
+ @Serializable
+ data class Backup(
+ @SerialName("dns_servers") val dnsServers: List,
+ @SerialName("auto_mode") val autoMode: Int?,
+ @SerialName("require_unlock") val requireUnlock: Boolean?,
+ )
+
+ @Serializable
+ data class LegacyBackup(
+ @SerialName("dns_servers") val dnsServers: String,
+ @SerialName("auto_mode") val autoMode: Int?,
+ @SerialName("require_unlock") val requireUnlock: Boolean?,
+ )
+
+ /**
+ * Exports all the preferences
+ * @param viewModel View model
+ * @param sharedPreferences Shared preferences
+ */
+ fun export(viewModel: DnsServerViewModel, sharedPreferences: SharedPreferences): Backup {
+ return Backup(
+ viewModel.allServers.value ?: listOf(),
+ sharedPreferences.autoMode,
+ sharedPreferences.requireUnlock
+ )
+ }
+
+ /**
+ * Imports all the preferences
+ * @param backup Deserialized backup
+ * @param viewModel View model
+ */
+ fun import(
+ backup: Backup,
+ viewModel: DnsServerViewModel,
+ sharedPreferences: SharedPreferences
+ ) {
+ backup.dnsServers.forEach { viewModel.insert(it) }
+ sharedPreferences.autoMode = backup.autoMode ?: sharedPreferences.autoMode
+ sharedPreferences.requireUnlock = backup.requireUnlock ?: sharedPreferences.requireUnlock
+ }
+
+ /**
+ * Imports old server list
+ * @param legacyBackup Deserialized backup
+ * @param viewModel View model
+ * @param sharedPreferences Shared preferences
+ */
+ fun importLegacy(
+ legacyBackup: LegacyBackup,
+ viewModel: DnsServerViewModel,
+ sharedPreferences: SharedPreferences
+ ) {
+ legacyBackup.dnsServers.let { servers ->
+ val serverList = servers.split(",")
+ serverList.forEach { server ->
+ val parts = server.split(" : ")
+ if (parts.size == 2) {
+ viewModel.insert(DnsServer(0, parts[1], parts[0]))
+ } else {
+ viewModel.insert(DnsServer(0, server, ""))
+ }
+ }
+ }
+ sharedPreferences.autoMode = legacyBackup.autoMode?: 0
+ sharedPreferences.requireUnlock = legacyBackup.requireUnlock == true
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/ru/karasevm/privatednstoggle/util/PrivateDNSUtils.kt b/app/src/main/java/ru/karasevm/privatednstoggle/util/PrivateDNSUtils.kt
new file mode 100644
index 0000000..9441421
--- /dev/null
+++ b/app/src/main/java/ru/karasevm/privatednstoggle/util/PrivateDNSUtils.kt
@@ -0,0 +1,52 @@
+package ru.karasevm.privatednstoggle.util
+
+import android.Manifest
+import android.content.ContentResolver
+import android.content.Context
+import android.content.pm.PackageManager
+import android.provider.Settings
+import androidx.core.content.ContextCompat.checkSelfPermission
+
+@Suppress("unused")
+object PrivateDNSUtils {
+ const val DNS_MODE_OFF = "off"
+ const val DNS_MODE_AUTO = "opportunistic"
+ const val DNS_MODE_PRIVATE = "hostname"
+
+ const val AUTO_MODE_OPTION_OFF = 0
+ const val AUTO_MODE_OPTION_AUTO = 1
+ const val AUTO_MODE_OPTION_OFF_AUTO = 2
+ const val AUTO_MODE_OPTION_PRIVATE = 3
+
+ private const val PRIVATE_DNS_MODE = "private_dns_mode"
+ private const val PRIVATE_DNS_PROVIDER = "private_dns_specifier"
+
+
+ // Gets the system dns mode
+ fun getPrivateMode(contentResolver: ContentResolver): String {
+ return Settings.Global.getString(contentResolver, PRIVATE_DNS_MODE)
+ }
+
+ // Gets the system dns provider
+ fun getPrivateProvider(contentResolver: ContentResolver): String {
+ return Settings.Global.getString(contentResolver, PRIVATE_DNS_PROVIDER)
+ }
+
+ // Sets the system dns mode
+ fun setPrivateMode(contentResolver: ContentResolver, value: String) {
+ Settings.Global.putString(contentResolver, PRIVATE_DNS_MODE, value)
+ }
+
+ // Sets the system dns provider
+ fun setPrivateProvider(contentResolver: ContentResolver, value: String?) {
+ Settings.Global.putString(contentResolver, PRIVATE_DNS_PROVIDER, value)
+ }
+
+ fun checkForPermission(context: Context): Boolean {
+ return checkSelfPermission(
+ context,
+ Manifest.permission.WRITE_SECURE_SETTINGS
+ ) == PackageManager.PERMISSION_GRANTED
+ }
+
+}
\ No newline at end of file
diff --git a/app/src/main/java/ru/karasevm/privatednstoggle/util/SharedPrefUtils.kt b/app/src/main/java/ru/karasevm/privatednstoggle/util/SharedPrefUtils.kt
new file mode 100644
index 0000000..129a56b
--- /dev/null
+++ b/app/src/main/java/ru/karasevm/privatednstoggle/util/SharedPrefUtils.kt
@@ -0,0 +1,57 @@
+package ru.karasevm.privatednstoggle.util
+
+import android.content.Context
+import android.content.SharedPreferences
+
+object PreferenceHelper {
+
+ const val DNS_SERVERS = "dns_servers"
+ const val AUTO_MODE = "auto_mode"
+ const val REQUIRE_UNLOCK = "require_unlock"
+
+ fun defaultPreference(context: Context): SharedPreferences =
+ context.getSharedPreferences("app_prefs", 0)
+
+ private inline fun SharedPreferences.editMe(operation: (SharedPreferences.Editor) -> Unit) {
+ val editMe = edit()
+ operation(editMe)
+ editMe.apply()
+ }
+
+ private fun SharedPreferences.Editor.put(pair: Pair) {
+ val key = pair.first
+ when (val value = pair.second) {
+ is String -> putString(key, value)
+ is Int -> putInt(key, value)
+ is Boolean -> putBoolean(key, value)
+ is Long -> putLong(key, value)
+ is Float -> putFloat(key, value)
+ else -> error("Only primitive types can be stored in SharedPreferences, got ${value.javaClass}")
+ }
+ }
+
+ var SharedPreferences.dns_servers
+ get() = getString(DNS_SERVERS, "")!!.split(",").toMutableList()
+ set(items) {
+ editMe {
+ it.put(DNS_SERVERS to items.joinToString(separator = ","))
+ }
+ }
+
+
+ var SharedPreferences.autoMode
+ get() = getInt(AUTO_MODE, PrivateDNSUtils.AUTO_MODE_OPTION_OFF)
+ set(value) {
+ editMe {
+ it.put(AUTO_MODE to value)
+ }
+ }
+
+ var SharedPreferences.requireUnlock
+ get() = getBoolean(REQUIRE_UNLOCK, false)
+ set(value) {
+ editMe {
+ it.put(REQUIRE_UNLOCK to value)
+ }
+ }
+}
diff --git a/app/src/main/java/ru/karasevm/privatednstoggle/util/ShizukuUtil.kt b/app/src/main/java/ru/karasevm/privatednstoggle/util/ShizukuUtil.kt
new file mode 100644
index 0000000..fe9b5cb
--- /dev/null
+++ b/app/src/main/java/ru/karasevm/privatednstoggle/util/ShizukuUtil.kt
@@ -0,0 +1,82 @@
+package ru.karasevm.privatednstoggle.util
+
+import android.Manifest
+import android.content.Context
+import android.content.pm.IPackageManager
+import android.os.Build
+import android.os.Process
+import android.os.UserHandle
+import android.permission.IPermissionManager
+import android.util.Log
+import org.lsposed.hiddenapibypass.HiddenApiBypass
+import rikka.shizuku.ShizukuBinderWrapper
+import rikka.shizuku.SystemServiceHelper
+import ru.karasevm.privatednstoggle.util.PrivateDNSUtils.checkForPermission
+
+object ShizukuUtil {
+
+ private const val TAG = "ShizukuUtil"
+
+ /**
+ * Attempts to grant the WRITE_SECURE_SETTINGS permission using Shizuku.
+ *
+ * @param context The context from which the method is called.
+ * @return True if the permission was granted successfully, false otherwise.
+ */
+ fun grantPermissionWithShizuku(context: Context): Boolean {
+ val packageName = context.packageName
+ var userId = 0
+ runCatching {
+ val userHandle = Process.myUserHandle()
+ userId = UserHandle::class.java.getMethod("getIdentifier").invoke(userHandle) as? Int ?: 0
+ }
+ if (Build.VERSION.SDK_INT >= 31) {
+ HiddenApiBypass.addHiddenApiExemptions("Landroid/permission")
+ val binder =
+ ShizukuBinderWrapper(SystemServiceHelper.getSystemService("permissionmgr"))
+ val pm = IPermissionManager.Stub.asInterface(binder)
+ runCatching {
+ pm.grantRuntimePermission(
+ packageName,
+ Manifest.permission.WRITE_SECURE_SETTINGS,
+ userId
+ )
+ }.onFailure { e ->
+ Log.w(TAG, "Android 12 method failed: ", e)
+ runCatching {
+ pm.grantRuntimePermission(
+ packageName,
+ Manifest.permission.WRITE_SECURE_SETTINGS,
+ 0,
+ userId
+ )
+ }.onFailure { e ->
+ Log.w(TAG, "Android 14 QPR2 method failed: ", e)
+ runCatching {
+ pm.grantRuntimePermission(
+ packageName,
+ Manifest.permission.WRITE_SECURE_SETTINGS,
+ "default:0",
+ userId
+ )
+ }.onFailure { e ->
+ Log.w(TAG, "Android 14 QPR3 method failed: ", e)
+ }
+ }
+ }
+ } else {
+ val binder = ShizukuBinderWrapper(SystemServiceHelper.getSystemService("package"))
+ val pm = IPackageManager.Stub.asInterface(binder)
+ runCatching {
+ pm.grantRuntimePermission(
+ packageName,
+ Manifest.permission.WRITE_SECURE_SETTINGS,
+ userId
+ )
+ }.onFailure { e ->
+ Log.w(TAG, "Android <12 method failed: ", e)
+ }
+ }
+ return checkForPermission(context)
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/res/drawable-anydpi/ic_tile_default.xml b/app/src/main/res/drawable-anydpi/ic_tile_default.xml
deleted file mode 100644
index 2af57be..0000000
--- a/app/src/main/res/drawable-anydpi/ic_tile_default.xml
+++ /dev/null
@@ -1,15 +0,0 @@
-
-
-
-
-
diff --git a/app/src/main/res/drawable-hdpi/ic_tile_default.png b/app/src/main/res/drawable-hdpi/ic_tile_default.png
deleted file mode 100644
index 9809f36..0000000
Binary files a/app/src/main/res/drawable-hdpi/ic_tile_default.png and /dev/null differ
diff --git a/app/src/main/res/drawable-mdpi/ic_tile_default.png b/app/src/main/res/drawable-mdpi/ic_tile_default.png
deleted file mode 100644
index cb09290..0000000
Binary files a/app/src/main/res/drawable-mdpi/ic_tile_default.png and /dev/null differ
diff --git a/app/src/main/res/drawable-xhdpi/ic_tile_default.png b/app/src/main/res/drawable-xhdpi/ic_tile_default.png
deleted file mode 100644
index 5564007..0000000
Binary files a/app/src/main/res/drawable-xhdpi/ic_tile_default.png and /dev/null differ
diff --git a/app/src/main/res/drawable-xxhdpi/ic_tile_default.png b/app/src/main/res/drawable-xxhdpi/ic_tile_default.png
deleted file mode 100644
index 35f5c5f..0000000
Binary files a/app/src/main/res/drawable-xxhdpi/ic_tile_default.png and /dev/null differ
diff --git a/app/src/main/res/drawable/ic_baseline_settings_24.xml b/app/src/main/res/drawable/ic_baseline_settings_24.xml
new file mode 100644
index 0000000..e7e3d4f
--- /dev/null
+++ b/app/src/main/res/drawable/ic_baseline_settings_24.xml
@@ -0,0 +1,10 @@
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/drawable/ic_drag_handle_24.xml b/app/src/main/res/drawable/ic_drag_handle_24.xml
new file mode 100644
index 0000000..892b734
--- /dev/null
+++ b/app/src/main/res/drawable/ic_drag_handle_24.xml
@@ -0,0 +1,10 @@
+
+
+
diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml
index 2a4e3e9..88e1536 100644
--- a/app/src/main/res/layout/activity_main.xml
+++ b/app/src/main/res/layout/activity_main.xml
@@ -4,23 +4,24 @@
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
- tools:context=".MainActivity">
+ tools:context=".ui.MainActivity"
+ android:fitsSystemWindows="true">
+ app:layout_constraintRight_toLeftOf="parent"
+ app:layout_constraintTop_toTopOf="parent">
+ app:menu="@menu/menu_main"
+ app:title="@string/app_name" />
@@ -30,6 +31,30 @@
android:layout_height="wrap_content"
app:layout_constraintTop_toBottomOf="@+id/topAppBarLayout" />
+
+
+
+
+
+
+
+
+
+
+
+
+ app:layout_constraintTop_toBottomOf="@+id/editTextServerHintLayout">
+ android:inputType="textNoSuggestions"
+ android:maxLines="1" />
\ No newline at end of file
diff --git a/app/src/main/res/layout/dialog_options.xml b/app/src/main/res/layout/dialog_options.xml
new file mode 100644
index 0000000..11f6ce1
--- /dev/null
+++ b/app/src/main/res/layout/dialog_options.xml
@@ -0,0 +1,65 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/layout/recyclerview_row.xml b/app/src/main/res/layout/recyclerview_row.xml
index fa7bb16..4da379a 100644
--- a/app/src/main/res/layout/recyclerview_row.xml
+++ b/app/src/main/res/layout/recyclerview_row.xml
@@ -1,22 +1,45 @@
+ android:focusable="true"
+ android:foreground="?android:attr/selectableItemBackground">
+ app:layout_constraintTop_toTopOf="parent" />
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/layout/sheet_dns_selector.xml b/app/src/main/res/layout/sheet_dns_selector.xml
new file mode 100644
index 0000000..bbe5b42
--- /dev/null
+++ b/app/src/main/res/layout/sheet_dns_selector.xml
@@ -0,0 +1,22 @@
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/menu/menu_main.xml b/app/src/main/res/menu/menu_main.xml
index 86318a2..fcfec62 100644
--- a/app/src/main/res/menu/menu_main.xml
+++ b/app/src/main/res/menu/menu_main.xml
@@ -2,10 +2,38 @@