2 | Private DNS Quick Toggle is a quick settings tile to switch your private
3 | dns provider.
4 | Supports any number of providers. Makes it easy to turn ad-blocking
5 | dns servers on or off with just a single tap.
6 |
7 |
8 | Permissions
9 |
10 | Requires WRITE_SECURE_SETTINGS permission to change the private dns settings.
11 | The permission must be provided either with Shizuku or
12 |
13 | manually through adb
14 | .
15 |
--------------------------------------------------------------------------------
/fastlane/metadata/android/en-US/changelogs/19.txt:
--------------------------------------------------------------------------------
1 | - Two new shortcuts - Next Server and Switch Mode (thanks @InfiniteCoder06 for groundwork)
2 | - Updated libraries and targetSdk for Android 16.
3 | - Added a list of used open-source libraries.
4 | - Chinese (Traditional Han script) translation by: 張恩愷 (kevin0910305), Jia-Bin (@OrStudio)
5 | - Hungarian translation by: Pacuka (@Pacuka)
6 | - Italian translation by: Champ0999 (@Champ0999)
7 | - Serbian translation by: zivojin kuzmanovic (kuzmanovic.zivojin2)
8 | - Ukrainian translation by: Dmitriy (@dmytro-kerest)
9 | - Vietnamese translation by: tuấn nguyễn (nguyentuan9834nblkh)
10 |
--------------------------------------------------------------------------------
/app/src/main/res/values-night/themes.xml:
--------------------------------------------------------------------------------
1 |
2 |
12 |
--------------------------------------------------------------------------------
/fastlane/metadata/android/en-US/changelogs/18.txt:
--------------------------------------------------------------------------------
1 | - Add Shizuku support for newer Android versions
2 | - Fix some crashes
3 | - Improve Shizuku process feedback
4 | - Fix Shizuku when not running as the primary user
5 | - Hungarian translation by @Pacuka in https://github.com/karasevm/PrivateDNSAndroid/pull/43
6 | - Add Polish translation (Michal L (@chuckmichael), Eryk Michalak (gnu-ewm))
7 | - Add Mongolian translation (Purevbaatar Tuvshinjargal (@puujee0238))
8 | - Add Portuguese (Brazil) translation (ajan, Víctor Assunção (@JoaoVictorAS))
9 | - Add Vietnamese translation (tuấn nguyễn (@Tuan1-2-3))
10 | - Add French translation (papaindiatango)
11 | - Add Tamil translation (தமிழ்நேரம் (@TamilNeram))
12 | - Add Turkish translation (Mustafa A. (mistiik99))
13 |
--------------------------------------------------------------------------------
/app/src/main/res/drawable/ic_shortcut_mode_24dp.xml:
--------------------------------------------------------------------------------
1 |
7 |
10 |
13 |
--------------------------------------------------------------------------------
/app/src/main/java/ru/karasevm/privatednstoggle/ShortcutHandlerActivity.kt:
--------------------------------------------------------------------------------
1 | package ru.karasevm.privatednstoggle
2 |
3 | import android.content.Intent
4 | import android.os.Bundle
5 | import androidx.appcompat.app.AppCompatActivity
6 | import ru.karasevm.privatednstoggle.service.ShortcutService
7 |
8 | class ShortcutHandlerActivity : AppCompatActivity() {
9 | override fun onCreate(savedInstanceState: Bundle?) {
10 | super.onCreate(savedInstanceState)
11 | if (intent != null && intent.data != null) {
12 | // Start the service when the shortcut is clicked
13 | val serviceIntent = Intent(this, ShortcutService::class.java)
14 | serviceIntent.data = intent.data
15 | startService(serviceIntent)
16 | finish()
17 | }
18 | }
19 | }
--------------------------------------------------------------------------------
/app/src/main/res/values/colors.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 | #498EE8
4 | #FF2E2E
5 |
6 | #6750A4
7 | #6750A4
8 | #625B71
9 | #7D5260
10 | #FFFBFE
11 |
12 | #D0BCFF
13 | #CCC2DC
14 | #EFB8C8
15 | #1C1B1F
16 |
17 |
--------------------------------------------------------------------------------
/app/src/main/java/ru/karasevm/privatednstoggle/model/DnsServer.kt:
--------------------------------------------------------------------------------
1 | package ru.karasevm.privatednstoggle.model
2 |
3 | import androidx.room.ColumnInfo
4 | import androidx.room.Entity
5 | import androidx.room.PrimaryKey
6 | import kotlinx.serialization.SerialName
7 | import kotlinx.serialization.Serializable
8 |
9 | // All fields must have default values for proper deserialization
10 | @Serializable
11 | @Entity(tableName = "dns_servers")
12 | data class DnsServer(
13 | @SerialName("id")
14 | @PrimaryKey(autoGenerate = true)
15 | val id: Int = 0,
16 | @SerialName("server")
17 | val server: String = "",
18 | @SerialName("label")
19 | val label: String = "",
20 | @SerialName("enabled")
21 | @ColumnInfo(defaultValue = "1")
22 | val enabled: Boolean = true,
23 | val sortOrder: Int? = null
24 | )
25 |
--------------------------------------------------------------------------------
/app/proguard-rules.pro:
--------------------------------------------------------------------------------
1 | # Add project specific ProGuard rules here.
2 | # You can control the set of applied configuration files using the
3 | # proguardFiles setting in build.gradle.
4 | #
5 | # For more details, see
6 | # http://developer.android.com/guide/developing/tools/proguard.html
7 |
8 | # If your project uses WebView with JS, uncomment the following
9 | # and specify the fully qualified class name to the JavaScript interface
10 | # class:
11 | #-keepclassmembers class fqcn.of.javascript.interface.for.webview {
12 | # public *;
13 | #}
14 |
15 | # Uncomment this to preserve the line number information for
16 | # debugging stack traces.
17 | #-keepattributes SourceFile,LineNumberTable
18 |
19 | # If you keep the line number information, uncomment this to
20 | # hide the original source file name.
21 | #-renamesourcefileattribute SourceFile
--------------------------------------------------------------------------------
/app/src/androidTest/java/ru/karasevm/privatednstoggle/ExampleInstrumentedTest.kt:
--------------------------------------------------------------------------------
1 | package ru.karasevm.privatednstoggle
2 |
3 | import androidx.test.platform.app.InstrumentationRegistry
4 | import androidx.test.ext.junit.runners.AndroidJUnit4
5 |
6 | import org.junit.Test
7 | import org.junit.runner.RunWith
8 |
9 | import org.junit.Assert.*
10 |
11 | /**
12 | * Instrumented test, which will execute on an Android device.
13 | *
14 | * See [testing documentation](http://d.android.com/tools/testing).
15 | */
16 | @RunWith(AndroidJUnit4::class)
17 | class ExampleInstrumentedTest {
18 | @Test
19 | fun useAppContext() {
20 | // Context of the app under test.
21 | val appContext = InstrumentationRegistry.getInstrumentation().targetContext
22 | assertEquals("ru.karasevm.privatednstoggle", appContext.packageName)
23 | }
24 | }
--------------------------------------------------------------------------------
/app/src/main/res/drawable/ic_auto_black_24dp.xml:
--------------------------------------------------------------------------------
1 |
6 |
10 |
13 |
14 |
--------------------------------------------------------------------------------
/app/src/main/res/layout/sheet_dns_selector.xml:
--------------------------------------------------------------------------------
1 |
2 |
6 |
7 |
14 |
15 |
20 |
21 |
22 |
--------------------------------------------------------------------------------
/app/src/main/res/drawable/ic_baseline_settings_24.xml:
--------------------------------------------------------------------------------
1 |
7 |
10 |
--------------------------------------------------------------------------------
/app/src/main/res/drawable/ic_unknown_black_24dp.xml:
--------------------------------------------------------------------------------
1 |
6 |
9 |
13 |
14 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2021 Maksim Karasev
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/02-feature-request.yml:
--------------------------------------------------------------------------------
1 | name: Feature request
2 | description: Suggest an idea for this project.
3 | labels: ["enhancement"]
4 | assignees: ["karasevm"]
5 | body:
6 | - type: textarea
7 | id: problem
8 | attributes:
9 | label: Is your feature request related to a problem? Please describe.
10 | description: A clear and concise description of what the problem is.
11 | placeholder: I'm always frustrated when [...]
12 | validations:
13 | required: true
14 | - type: textarea
15 | id: solution
16 | attributes:
17 | label: Describe the solution you'd like.
18 | description: A clear and concise description of what you want to happen.
19 | validations:
20 | required: true
21 | - type: textarea
22 | id: alternative
23 | attributes:
24 | label: Describe alternatives you've considered.
25 | description: A clear and concise description of any alternative solutions or features you've considered.
26 | - type: textarea
27 | id: additional-context
28 | attributes:
29 | label: Additional context
30 | description: Add any other context about the problem here.
--------------------------------------------------------------------------------
/app/src/main/java/ru/karasevm/privatednstoggle/PrivateDNSApp.kt:
--------------------------------------------------------------------------------
1 | package ru.karasevm.privatednstoggle
2 |
3 | import android.app.Application
4 | import android.os.StrictMode
5 | import com.google.android.material.color.DynamicColors
6 | import ru.karasevm.privatednstoggle.data.DnsServerRepository
7 | import ru.karasevm.privatednstoggle.data.database.DnsServerRoomDatabase
8 |
9 | class PrivateDNSApp : Application() {
10 |
11 | private val database by lazy { DnsServerRoomDatabase.getDatabase(this) }
12 | val repository by lazy { DnsServerRepository(database.dnsServerDao()) }
13 |
14 | override fun onCreate() {
15 | super.onCreate()
16 | DynamicColors.applyToActivitiesIfAvailable(this)
17 |
18 | if (BuildConfig.DEBUG){
19 | StrictMode.setThreadPolicy(
20 | StrictMode.ThreadPolicy.Builder()
21 | .detectAll()
22 | .penaltyLog()
23 | .build()
24 | )
25 | StrictMode.setVmPolicy(
26 | StrictMode.VmPolicy.Builder()
27 | .detectAll()
28 | .penaltyLog()
29 | .build()
30 | )
31 | }
32 | }
33 | }
--------------------------------------------------------------------------------
/app/src/main/res/drawable/ic_off_black_24dp.xml:
--------------------------------------------------------------------------------
1 |
6 |
9 |
12 |
13 |
--------------------------------------------------------------------------------
/app/src/debug/res/xml/shortcuts.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
8 |
13 |
14 |
19 |
24 |
25 |
--------------------------------------------------------------------------------
/app/src/main/res/xml/shortcuts.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
8 |
13 |
14 |
19 |
24 |
25 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Built application files
2 | *.apk
3 | *.ap_
4 | *.aab
5 |
6 | # Android Studio generated files and folders
7 | captures/
8 | .externalNativeBuild/
9 | .cxx/
10 | output.json
11 |
12 | # Release dir
13 | app/release/*
14 |
15 | # Files for the ART/Dalvik VM
16 | *.dex
17 |
18 | # Java class files
19 | *.class
20 |
21 | # Generated files
22 | bin/
23 | gen/
24 | out/
25 |
26 | # Gradle files
27 | .gradle/
28 | build/
29 |
30 | # Local configuration file (sdk path, etc)
31 | local.properties
32 |
33 | # Proguard folder generated by Eclipse
34 | proguard/
35 |
36 | # Log Files
37 | *.log
38 |
39 | # Android Studio Navigation editor temp files
40 | .navigation/
41 |
42 | # IntelliJ
43 | *.iml
44 | .idea/
45 | misc.xml
46 | deploymentTargetDropDown.xml
47 | render.experimental.xml
48 |
49 | # Keystore files
50 | *.jks
51 | *.keystore
52 |
53 | # External native build folder generated in Android Studio 2.2 and later
54 | .externalNativeBuild
55 |
56 | # Google Services (e.g. APIs or Firebase)
57 | google-services.json
58 |
59 | # Freeline
60 | freeline.py
61 | freeline/
62 | freeline_project_description.json
63 |
64 | # fastlane
65 | fastlane/report.xml
66 | fastlane/Preview.html
67 | fastlane/screenshots
68 | fastlane/test_output
69 | fastlane/readme.md
70 |
71 | # kotlin
72 | .kotlin/
73 |
74 | *~
75 |
--------------------------------------------------------------------------------
/app/src/main/java/ru/karasevm/privatednstoggle/data/database/DnsServerRoomDatabase.kt:
--------------------------------------------------------------------------------
1 | package ru.karasevm.privatednstoggle.data.database
2 |
3 | import android.content.Context
4 | import androidx.room.Database
5 | import androidx.room.Room
6 | import androidx.room.RoomDatabase
7 | import ru.karasevm.privatednstoggle.data.DnsServerDao
8 | import ru.karasevm.privatednstoggle.model.DnsServer
9 |
10 | @Database(entities = [DnsServer::class], version = 1, exportSchema = false)
11 | abstract class DnsServerRoomDatabase : RoomDatabase() {
12 |
13 | abstract fun dnsServerDao(): DnsServerDao
14 |
15 | companion object {
16 | @Volatile
17 | private var INSTANCE: DnsServerRoomDatabase? = null
18 | fun getDatabase(context: Context): DnsServerRoomDatabase {
19 | val tempInstance = INSTANCE
20 | if (tempInstance != null) {
21 | return tempInstance
22 | }
23 | synchronized(this) {
24 | val instance = Room.databaseBuilder(
25 | context.applicationContext,
26 | DnsServerRoomDatabase::class.java,
27 | "dns_server_database"
28 | ).build()
29 | INSTANCE = instance
30 | return instance
31 | }
32 | }
33 | }
34 | }
--------------------------------------------------------------------------------
/app/src/main/res/drawable/ic_shortcut_next_24dp.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/gradle.properties:
--------------------------------------------------------------------------------
1 | # Project-wide Gradle settings.
2 | # IDE (e.g. Android Studio) users:
3 | # Gradle settings configured through the IDE *will override*
4 | # any settings specified in this file.
5 | # For more details on how to configure your build environment visit
6 | # http://www.gradle.org/docs/current/userguide/build_environment.html
7 | # Specifies the JVM arguments used for the daemon process.
8 | # The setting is particularly useful for tweaking memory settings.
9 | org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
10 | # When configured, Gradle will run in incubating parallel mode.
11 | # This option should only be used with decoupled projects. More details, visit
12 | # http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects
13 | # org.gradle.parallel=true
14 | # AndroidX package structure to make it clearer which packages are bundled with the
15 | # Android operating system, and which are packaged with your app"s APK
16 | # https://developer.android.com/topic/libraries/support-library/androidx-rn
17 | android.useAndroidX=true
18 | # Automatically convert third-party libraries to use AndroidX
19 | android.enableJetifier=false
20 | # Kotlin code style for this project: "official" or "obsolete":
21 | kotlin.code.style=official
22 | android.nonTransitiveRClass=true
23 | android.nonFinalResIds=true
24 | org.gradle.configuration-cache=true
--------------------------------------------------------------------------------
/app/src/main/res/drawable/ic_private_black_24dp.xml:
--------------------------------------------------------------------------------
1 |
6 |
9 |
12 |
13 |
--------------------------------------------------------------------------------
/app/src/main/res/values/themes.xml:
--------------------------------------------------------------------------------
1 |
2 |
14 |
15 |
27 |
--------------------------------------------------------------------------------
/app/src/main/res/drawable/ic_launcher_foreground.xml:
--------------------------------------------------------------------------------
1 |
6 |
10 |
13 |
16 |
17 |
18 |
--------------------------------------------------------------------------------
/app/src/main/java/ru/karasevm/privatednstoggle/data/DnsServerRepository.kt:
--------------------------------------------------------------------------------
1 | package ru.karasevm.privatednstoggle.data
2 |
3 | import androidx.annotation.WorkerThread
4 | import kotlinx.coroutines.flow.Flow
5 | import ru.karasevm.privatednstoggle.model.DnsServer
6 |
7 | class DnsServerRepository(private val dnsServerDao: DnsServerDao) {
8 |
9 | val allServers: Flow> = dnsServerDao.getAll()
10 |
11 | @WorkerThread
12 | fun getAll() = dnsServerDao.getAll()
13 |
14 | @WorkerThread
15 | suspend fun getFirstEnabled() = dnsServerDao.getFirstEnabled()
16 |
17 | @WorkerThread
18 | suspend fun getById(id: Int) = dnsServerDao.getById(id)
19 |
20 | @WorkerThread
21 | suspend fun getFirstByServer(server: String) = dnsServerDao.getFirstByServer(server)
22 |
23 | @WorkerThread
24 | suspend fun getNextByServer(server: String) = dnsServerDao.getNextEnabledByServer(server)
25 |
26 | @WorkerThread
27 | suspend fun insert(dnsServer: DnsServer) {
28 | dnsServerDao.insert(dnsServer.server, dnsServer.label, dnsServer.enabled)
29 | }
30 |
31 | @WorkerThread
32 | suspend fun update(
33 | id: Int,
34 | server: String?,
35 | label: String?,
36 | sortOrder: Int?,
37 | enabled: Boolean?
38 | ) {
39 | dnsServerDao.update(id, server, label, sortOrder, enabled)
40 | }
41 |
42 | @WorkerThread
43 | suspend fun move(sortOrder: Int, newSortOrder: Int, id: Int) {
44 | if (sortOrder == newSortOrder) {
45 | return
46 | }
47 | if (newSortOrder > sortOrder) {
48 | dnsServerDao.moveDown(sortOrder, newSortOrder, id)
49 | } else {
50 | dnsServerDao.moveUp(sortOrder, newSortOrder, id)
51 | }
52 | }
53 |
54 | @WorkerThread
55 | suspend fun delete(id: Int) {
56 | dnsServerDao.deleteAndDecrement(id)
57 | }
58 |
59 |
60 | }
--------------------------------------------------------------------------------
/app/src/main/java/ru/karasevm/privatednstoggle/data/DnsServerViewModel.kt:
--------------------------------------------------------------------------------
1 | package ru.karasevm.privatednstoggle.data
2 |
3 | import androidx.lifecycle.LiveData
4 | import androidx.lifecycle.ViewModel
5 | import androidx.lifecycle.ViewModelProvider
6 | import androidx.lifecycle.asLiveData
7 | import androidx.lifecycle.viewModelScope
8 | import kotlinx.coroutines.launch
9 | import ru.karasevm.privatednstoggle.model.DnsServer
10 |
11 | class DnsServerViewModel(private val dnsServerRepository: DnsServerRepository) : ViewModel() {
12 |
13 | val allServers: LiveData> = dnsServerRepository.allServers.asLiveData()
14 |
15 | fun getAll() = dnsServerRepository.getAll()
16 |
17 | suspend fun getById(id: Int) = dnsServerRepository.getById(id)
18 |
19 | fun insert(dnsServer: DnsServer) =
20 | viewModelScope.launch {
21 | dnsServerRepository.insert(dnsServer)
22 | }
23 |
24 | fun update(
25 | id: Int,
26 | server: String? = null,
27 | label: String? = null,
28 | sortOrder: Int? = null,
29 | enabled: Boolean? = null
30 | ) = viewModelScope.launch { dnsServerRepository.update(id, server, label, sortOrder, enabled) }
31 |
32 | fun move(sortOrder: Int, newSortOrder: Int, id: Int) =
33 | viewModelScope.launch { dnsServerRepository.move(sortOrder, newSortOrder, id) }
34 |
35 | fun delete(id: Int) = viewModelScope.launch { dnsServerRepository.delete(id) }
36 |
37 | }
38 |
39 | class DnsServerViewModelFactory(private val dnsServerRepository: DnsServerRepository) :
40 | ViewModelProvider.Factory {
41 | override fun create(modelClass: Class): T {
42 | if (modelClass.isAssignableFrom(DnsServerViewModel::class.java)) {
43 | @Suppress("UNCHECKED_CAST")
44 | return DnsServerViewModel(dnsServerRepository) as T
45 | }
46 | throw IllegalArgumentException("Unknown ViewModel class")
47 | }
48 | }
--------------------------------------------------------------------------------
/app/src/main/res/menu/menu_main.xml:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/app/src/main/java/ru/karasevm/privatednstoggle/util/SharedPrefUtils.kt:
--------------------------------------------------------------------------------
1 | package ru.karasevm.privatednstoggle.util
2 |
3 | import android.content.Context
4 | import android.content.SharedPreferences
5 | import androidx.core.content.edit
6 |
7 | object PreferenceHelper {
8 |
9 | const val DNS_SERVERS = "dns_servers"
10 | const val AUTO_MODE = "auto_mode"
11 | const val REQUIRE_UNLOCK = "require_unlock"
12 |
13 | fun defaultPreference(context: Context): SharedPreferences =
14 | context.getSharedPreferences("app_prefs", 0)
15 |
16 | private inline fun SharedPreferences.editMe(operation: (SharedPreferences.Editor) -> Unit) {
17 | edit {
18 | operation(this)
19 | }
20 | }
21 |
22 | private fun SharedPreferences.Editor.put(pair: Pair) {
23 | val key = pair.first
24 | when (val value = pair.second) {
25 | is String -> putString(key, value)
26 | is Int -> putInt(key, value)
27 | is Boolean -> putBoolean(key, value)
28 | is Long -> putLong(key, value)
29 | is Float -> putFloat(key, value)
30 | else -> error("Only primitive types can be stored in SharedPreferences, got ${value.javaClass}")
31 | }
32 | }
33 |
34 | var SharedPreferences.dns_servers
35 | get() = getString(DNS_SERVERS, "")!!.split(",").toMutableList()
36 | set(items) {
37 | editMe {
38 | it.put(DNS_SERVERS to items.joinToString(separator = ","))
39 | }
40 | }
41 |
42 |
43 | var SharedPreferences.autoMode
44 | get() = getInt(AUTO_MODE, PrivateDNSUtils.AUTO_MODE_OPTION_OFF)
45 | set(value) {
46 | editMe {
47 | it.put(AUTO_MODE to value)
48 | }
49 | }
50 |
51 | var SharedPreferences.requireUnlock
52 | get() = getBoolean(REQUIRE_UNLOCK, false)
53 | set(value) {
54 | editMe {
55 | it.put(REQUIRE_UNLOCK to value)
56 | }
57 | }
58 | }
59 |
--------------------------------------------------------------------------------
/app/src/main/res/layout/recyclerview_row.xml:
--------------------------------------------------------------------------------
1 |
2 |
10 |
11 |
21 |
22 |
32 |
33 |
34 |
45 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/01-bug-report.yml:
--------------------------------------------------------------------------------
1 | name: Bug report
2 | description: File a bug report.
3 | labels: ['bug']
4 | assignees: ['karasevm']
5 | body:
6 | - type: markdown
7 | attributes:
8 | value: |
9 | Thanks for taking the time to fill out this bug report!
10 | - type: input
11 | id: app_version
12 | attributes:
13 | label: Application Version
14 | description: What version of the app are you running?
15 | placeholder: ex. 1.0
16 | validations:
17 | required: true
18 | - type: input
19 | id: android_version
20 | attributes:
21 | label: Application Version
22 | description: What version of Android you running?
23 | placeholder: ex. 13
24 | validations:
25 | required: true
26 | - type: input
27 | id: device
28 | attributes:
29 | label: Device
30 | description: What device are you using?
31 | placeholder: ex. Pixel 5
32 | validations:
33 | required: true
34 | - type: dropdown
35 | id: install_method
36 | attributes:
37 | label: How do you provide the permission?
38 | options:
39 | - Shizuku
40 | - ADB
41 | - Other
42 | validations:
43 | required: true
44 | - type: textarea
45 | id: what-happened
46 | attributes:
47 | label: What happened?
48 | description: Also tell us, what did you expect to happen?
49 | placeholder: A bug happened!
50 | validations:
51 | required: true
52 | - type: textarea
53 | id: steps-to-reproduce
54 | attributes:
55 | label: Steps to reproduce
56 | description: |
57 | Please describe what you did to reproduce the bug.
58 | - type: textarea
59 | id: logs
60 | attributes:
61 | label: Relevant log output
62 | description: Please copy and paste any relevant log output. This will be automatically formatted into code, so no need for backticks.
63 | render: shell
64 | - type: textarea
65 | id: screens
66 | attributes:
67 | label: Screenshots
68 | description: If applicable, add screenshots to help explain your problem.
69 | - type: textarea
70 | id: additional-context
71 | attributes:
72 | label: Additional context
73 | description: Add any other context about the problem here.
74 |
--------------------------------------------------------------------------------
/app/src/main/java/ru/karasevm/privatednstoggle/ui/DeleteServerDialogFragment.kt:
--------------------------------------------------------------------------------
1 | package ru.karasevm.privatednstoggle.ui
2 |
3 | import android.app.Dialog
4 | import android.content.Context
5 | import android.os.Bundle
6 | import androidx.fragment.app.DialogFragment
7 | import com.google.android.material.dialog.MaterialAlertDialogBuilder
8 | import ru.karasevm.privatednstoggle.R
9 |
10 |
11 | class DeleteServerDialogFragment(private val id: Int) : DialogFragment() {
12 | // Use this instance of the interface to deliver action events
13 | private lateinit var listener: NoticeDialogListener
14 |
15 | /* The activity that creates an instance of this dialog fragment must
16 | * implement this interface in order to receive event callbacks.
17 | * Each method passes the DialogFragment in case the host needs to query it. */
18 | interface NoticeDialogListener {
19 | fun onDeleteDialogPositiveClick(id: Int)
20 | }
21 |
22 | // Override the Fragment.onAttach() method to instantiate the NoticeDialogListener
23 | override fun onAttach(context: Context) {
24 | super.onAttach(context)
25 | // Verify that the host activity implements the callback interface
26 | try {
27 | // Instantiate the NoticeDialogListener so we can send events to the host
28 | listener = context as NoticeDialogListener
29 | } catch (e: ClassCastException) {
30 | // The activity doesn't implement the interface, throw exception
31 | throw ClassCastException(
32 | (context.toString() +
33 | " must implement NoticeDialogListener")
34 | )
35 | }
36 | }
37 |
38 | override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
39 | return activity?.let {
40 | val builder = MaterialAlertDialogBuilder(it)
41 |
42 | builder.setTitle(R.string.delete_question)
43 | .setMessage(R.string.delete_message)
44 | .setPositiveButton(
45 | R.string.delete
46 | ) { _, _ ->
47 | listener.onDeleteDialogPositiveClick(id)
48 | }
49 | .setNegativeButton(
50 | R.string.cancel
51 | ) { _, _ ->
52 | dialog?.cancel()
53 | }
54 | // Create the AlertDialog object and return it
55 | builder.create()
56 | } ?: throw IllegalStateException("Activity cannot be null")
57 | }
58 |
59 |
60 | }
--------------------------------------------------------------------------------
/app/src/main/res/layout/dialog_options.xml:
--------------------------------------------------------------------------------
1 |
2 |
6 |
7 |
14 |
15 |
22 |
23 |
28 |
29 |
34 |
35 |
40 |
41 |
46 |
47 |
48 |
49 |
50 |
54 |
55 |
64 |
65 |
--------------------------------------------------------------------------------
/app/src/main/res/values-zh-rCN/strings.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 | 私有DNS触发
4 | 必要权限未授予,请看相关说明
5 | 关闭
6 | 自动
7 | 未知
8 | 添加DNS服务器
9 | 添加
10 | 存储
11 | 隐私策略
12 | 选择服务器
13 | 完成
14 | 取消
15 | 删除条目
16 | 你确认要删除这个服务器条目吗?
17 | 删除
18 | 服务器地址不可为空
19 | DNS服务器标识
20 | DNS服务器地址
21 | 选项
22 | 确认
23 | 选择要在磁贴中启用的选项
24 | 仅“关闭”
25 | 仅“自动”
26 | “关闭“与”自动“
27 | 仅设置的私有DNS
28 | 打开软件
29 | 不使用私有DNS
30 | 自动使用私有DNS
31 | 设置为使用私有DNS\"%1$s\"
32 | 更改服务器设置要求设备解锁
33 | 拖动把手
34 | 导入
35 | 导出
36 | 已导入
37 | 导入失败
38 | 导入失败,json格式异常
39 | 已复制
40 | 从文件导入
41 | 从剪贴板导入
42 | 导出至剪贴板
43 | 分享
44 | 导出至文件
45 | 保存失败
46 | 保存成功
47 | 编辑服务器条目
48 | 无可用服务器
49 | 点击下方\"+\"添加一个吧
50 | 已启用
51 |
--------------------------------------------------------------------------------
/app/src/main/res/values-zh-rTW/strings.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 | 新增伺服器
4 | 新增
5 | 私人 DNS 切換
6 | 未授予權限,請檢查應用程式以了解如何操作
7 | 關閉
8 | 自動
9 | 未知
10 | 隱私權政策
11 | 選擇伺服器
12 | 完成
13 | 取消
14 | 刪除
15 | 刪除
16 | 伺服器地址不能為空
17 | DNS 伺服器標籤(可選)
18 | DNS伺服器地址
19 | 選項
20 | OK
21 | 僅關閉
22 | 僅自動
23 | 關閉和自動
24 | 僅私人DNS
25 | 打開應用程式
26 | 私人 DNS 已關閉
27 | 私人 DNS 設定為自動
28 | 私人 DNS 設定為 %1$s
29 | 需要解鎖設備才能更改伺服器
30 | 匯入
31 | 匯出
32 | 已匯入
33 | 匯入失敗
34 | 匯入失敗,JSON 格式錯誤
35 | 從剪貼簿
36 | 到剪貼簿
37 | 到檔案
38 | 保存失敗
39 | 保存成功
40 | 編輯伺服器
41 | 點擊下面的按鈕新增一個
42 | 已啟用
43 | 已授予權限,您現在可以撤銷 Shizuku 權限
44 | 取得權限失敗,請手動授予
45 | 儲存
46 | 確定要刪除伺服器嗎?
47 | 選擇要包含在磁貼中的選項
48 | 已複製
49 | 從檔案
50 | 分享
51 | 未添加伺服器
52 | Private DNS Quick Toggle
53 |
54 |
--------------------------------------------------------------------------------
/gradlew.bat:
--------------------------------------------------------------------------------
1 | @rem
2 | @rem Copyright 2015 the original author or authors.
3 | @rem
4 | @rem Licensed under the Apache License, Version 2.0 (the "License");
5 | @rem you may not use this file except in compliance with the License.
6 | @rem You may obtain a copy of the License at
7 | @rem
8 | @rem https://www.apache.org/licenses/LICENSE-2.0
9 | @rem
10 | @rem Unless required by applicable law or agreed to in writing, software
11 | @rem distributed under the License is distributed on an "AS IS" BASIS,
12 | @rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | @rem See the License for the specific language governing permissions and
14 | @rem limitations under the License.
15 | @rem
16 |
17 | @if "%DEBUG%" == "" @echo off
18 | @rem ##########################################################################
19 | @rem
20 | @rem Gradle startup script for Windows
21 | @rem
22 | @rem ##########################################################################
23 |
24 | @rem Set local scope for the variables with windows NT shell
25 | if "%OS%"=="Windows_NT" setlocal
26 |
27 | set DIRNAME=%~dp0
28 | if "%DIRNAME%" == "" set DIRNAME=.
29 | set APP_BASE_NAME=%~n0
30 | set APP_HOME=%DIRNAME%
31 |
32 | @rem Resolve any "." and ".." in APP_HOME to make it shorter.
33 | for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
34 |
35 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
36 | set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
37 |
38 | @rem Find java.exe
39 | if defined JAVA_HOME goto findJavaFromJavaHome
40 |
41 | set JAVA_EXE=java.exe
42 | %JAVA_EXE% -version >NUL 2>&1
43 | if "%ERRORLEVEL%" == "0" goto execute
44 |
45 | echo.
46 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
47 | echo.
48 | echo Please set the JAVA_HOME variable in your environment to match the
49 | echo location of your Java installation.
50 |
51 | goto fail
52 |
53 | :findJavaFromJavaHome
54 | set JAVA_HOME=%JAVA_HOME:"=%
55 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe
56 |
57 | if exist "%JAVA_EXE%" goto execute
58 |
59 | echo.
60 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
61 | echo.
62 | echo Please set the JAVA_HOME variable in your environment to match the
63 | echo location of your Java installation.
64 |
65 | goto fail
66 |
67 | :execute
68 | @rem Setup the command line
69 |
70 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
71 |
72 |
73 | @rem Execute Gradle
74 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
75 |
76 | :end
77 | @rem End local scope for the variables with windows NT shell
78 | if "%ERRORLEVEL%"=="0" goto mainEnd
79 |
80 | :fail
81 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
82 | rem the _cmd.exe /c_ return code!
83 | if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
84 | exit /b 1
85 |
86 | :mainEnd
87 | if "%OS%"=="Windows_NT" endlocal
88 |
89 | :omega
90 |
--------------------------------------------------------------------------------
/app/src/main/res/layout/dialog_add.xml:
--------------------------------------------------------------------------------
1 |
2 |
6 |
7 |
20 |
21 |
30 |
31 |
39 |
40 |
41 |
42 |
52 |
53 |
61 |
62 |
63 |
--------------------------------------------------------------------------------
/app/src/main/res/values-mn/strings.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 | Хувийн DNS солих
4 | Унтраах
5 | Тодорхойгүй
6 | Сервер нэмэх
7 | Хадгалах
8 | Болсон
9 | Болих
10 | Устгах
11 | Та серверийг устгахдаа итгэлтэй байна уу?
12 | Устгах
13 | Серверийн хаяг хоосон байж болохгүй
14 | DNS серверийн шошго (заавал биш)
15 | DNS серверийн хаяг
16 | Сонголтууд
17 | ОК
18 | Хавтан дээр ямар сонголтыг оруулахаа сонгоно уу
19 | Зөвхөн унтарсан
20 | Зөвхөн авто
21 | Унтарсан болон авто
22 | Хувийн DNS-г %1$s болгож тохируулсан
23 | Серверийг өөрчлөхийн тулд төхөөрөмжийн түгжээг тайлах шаардлагатай
24 | Бариулыг чирэх
25 | Импорт
26 | Импортолсон
27 | Импорт хийж чадсангүй
28 | Файлаас
29 | Хадгалж чадсангүй
30 | Амжилттай хадгалсан
31 | Сервер засах
32 | Хувийн DNS хурдан сэлгэх
33 | Нууцлалын бодлого
34 | Зөвшөөрөл олгоогүй. Үүнийг хэрхэн хийхийг харна уу
35 | Авто
36 | Нэмэх
37 | Сервер сонгох
38 | Зөвхөн хувийн DNS
39 | Апп нээх
40 | Түр санах ой руу
41 | Хувийн DNS унтарсан
42 | Хувийн DNS-г автоматаар тохируулсан
43 | Экспорт
44 | Хуулагдсан
45 | Хуваалцах
46 | Импорт хийж чадсангүй, алдаатай JSON
47 | Түр санах ойноос
48 | Файлруу
49 |
--------------------------------------------------------------------------------
/app/src/main/java/ru/karasevm/privatednstoggle/ui/AboutLibsActivity.kt:
--------------------------------------------------------------------------------
1 | package ru.karasevm.privatednstoggle.ui
2 |
3 | import android.os.Build
4 | import android.os.Bundle
5 | import androidx.activity.compose.setContent
6 | import androidx.activity.enableEdgeToEdge
7 | import androidx.appcompat.app.AppCompatActivity
8 | import androidx.compose.foundation.isSystemInDarkTheme
9 | import androidx.compose.material.icons.Icons
10 | import androidx.compose.material.icons.automirrored.filled.ArrowBack
11 | import androidx.compose.material3.ExperimentalMaterial3Api
12 | import androidx.compose.material3.Icon
13 | import androidx.compose.material3.IconButton
14 | import androidx.compose.material3.MaterialTheme
15 | import androidx.compose.material3.Scaffold
16 | import androidx.compose.material3.Text
17 | import androidx.compose.material3.TopAppBar
18 | import androidx.compose.material3.darkColorScheme
19 | import androidx.compose.material3.dynamicDarkColorScheme
20 | import androidx.compose.material3.dynamicLightColorScheme
21 | import androidx.compose.material3.lightColorScheme
22 | import androidx.compose.runtime.getValue
23 | import com.mikepenz.aboutlibraries.ui.compose.android.rememberLibraries
24 | import com.mikepenz.aboutlibraries.ui.compose.m3.LibrariesContainer
25 | import ru.karasevm.privatednstoggle.R
26 |
27 | class AboutLibsActivity : AppCompatActivity() {
28 | @OptIn(ExperimentalMaterial3Api::class)
29 | override fun onCreate(savedInstanceState: Bundle?) {
30 | super.onCreate(savedInstanceState)
31 | enableEdgeToEdge()
32 | setContent {
33 | MaterialTheme(
34 | colorScheme = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
35 | if (isSystemInDarkTheme())
36 | dynamicDarkColorScheme(applicationContext)
37 | else
38 | dynamicLightColorScheme(applicationContext)
39 | } else {
40 | if (isSystemInDarkTheme()) {
41 | darkColorScheme()
42 | } else {
43 | lightColorScheme()
44 | }
45 | }
46 | ) {
47 | val libraries by rememberLibraries(R.raw.aboutlibraries)
48 | Scaffold(
49 | topBar = {
50 | TopAppBar(
51 | title = { Text("Libraries") },
52 | navigationIcon = {
53 | IconButton(onClick = { finish() }) {
54 | Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "back")
55 | }
56 | }
57 | )
58 | },
59 | content = { paddingValues ->
60 | LibrariesContainer(
61 | libraries = libraries,
62 | contentPadding = paddingValues
63 | )
64 | }
65 | )
66 | }
67 | }
68 | }
69 | }
--------------------------------------------------------------------------------
/app/src/main/java/ru/karasevm/privatednstoggle/util/BackupUtils.kt:
--------------------------------------------------------------------------------
1 | package ru.karasevm.privatednstoggle.util
2 |
3 | import android.content.SharedPreferences
4 | import kotlinx.serialization.SerialName
5 | import kotlinx.serialization.Serializable
6 | import ru.karasevm.privatednstoggle.data.DnsServerViewModel
7 | import ru.karasevm.privatednstoggle.model.DnsServer
8 | import ru.karasevm.privatednstoggle.util.PreferenceHelper.autoMode
9 | import ru.karasevm.privatednstoggle.util.PreferenceHelper.requireUnlock
10 |
11 | object BackupUtils {
12 | @Serializable
13 | data class Backup(
14 | @SerialName("dns_servers") val dnsServers: List,
15 | @SerialName("auto_mode") val autoMode: Int?,
16 | @SerialName("require_unlock") val requireUnlock: Boolean?,
17 | )
18 |
19 | @Serializable
20 | data class LegacyBackup(
21 | @SerialName("dns_servers") val dnsServers: String,
22 | @SerialName("auto_mode") val autoMode: Int?,
23 | @SerialName("require_unlock") val requireUnlock: Boolean?,
24 | )
25 |
26 | /**
27 | * Exports all the preferences
28 | * @param viewModel View model
29 | * @param sharedPreferences Shared preferences
30 | */
31 | fun export(viewModel: DnsServerViewModel, sharedPreferences: SharedPreferences): Backup {
32 | return Backup(
33 | viewModel.allServers.value ?: listOf(),
34 | sharedPreferences.autoMode,
35 | sharedPreferences.requireUnlock
36 | )
37 | }
38 |
39 | /**
40 | * Imports all the preferences
41 | * @param backup Deserialized backup
42 | * @param viewModel View model
43 | */
44 | fun import(
45 | backup: Backup,
46 | viewModel: DnsServerViewModel,
47 | sharedPreferences: SharedPreferences
48 | ) {
49 | backup.dnsServers.forEach { viewModel.insert(it) }
50 | sharedPreferences.autoMode = backup.autoMode ?: sharedPreferences.autoMode
51 | sharedPreferences.requireUnlock = backup.requireUnlock ?: sharedPreferences.requireUnlock
52 | }
53 |
54 | /**
55 | * Imports old server list
56 | * @param legacyBackup Deserialized backup
57 | * @param viewModel View model
58 | * @param sharedPreferences Shared preferences
59 | */
60 | fun importLegacy(
61 | legacyBackup: LegacyBackup,
62 | viewModel: DnsServerViewModel,
63 | sharedPreferences: SharedPreferences
64 | ) {
65 | legacyBackup.dnsServers.let { servers ->
66 | val serverList = servers.split(",")
67 | serverList.forEach { server ->
68 | val parts = server.split(" : ")
69 | if (parts.size == 2) {
70 | viewModel.insert(DnsServer(0, parts[1], parts[0]))
71 | } else {
72 | viewModel.insert(DnsServer(0, server, ""))
73 | }
74 | }
75 | }
76 | sharedPreferences.autoMode = legacyBackup.autoMode?: 0
77 | sharedPreferences.requireUnlock = legacyBackup.requireUnlock == true
78 | }
79 | }
--------------------------------------------------------------------------------
/app/src/main/java/ru/karasevm/privatednstoggle/ui/OptionsDialogFragment.kt:
--------------------------------------------------------------------------------
1 | package ru.karasevm.privatednstoggle.ui
2 |
3 | import android.app.Dialog
4 | import android.os.Bundle
5 | import androidx.fragment.app.DialogFragment
6 | import com.google.android.material.dialog.MaterialAlertDialogBuilder
7 | import ru.karasevm.privatednstoggle.R
8 | import ru.karasevm.privatednstoggle.databinding.DialogOptionsBinding
9 | import ru.karasevm.privatednstoggle.util.PreferenceHelper
10 | import ru.karasevm.privatednstoggle.util.PreferenceHelper.autoMode
11 | import ru.karasevm.privatednstoggle.util.PreferenceHelper.requireUnlock
12 | import ru.karasevm.privatednstoggle.util.PrivateDNSUtils
13 |
14 | class OptionsDialogFragment : DialogFragment() {
15 | private var _binding: DialogOptionsBinding? = null
16 | private val binding get() = _binding!!
17 | private val sharedPreferences by lazy { PreferenceHelper.defaultPreference(requireContext()) }
18 |
19 | override fun onCreateDialog(
20 | savedInstanceState: Bundle?
21 | ): Dialog {
22 | return activity?.let {
23 | val builder = MaterialAlertDialogBuilder(it)
24 | val inflater = requireActivity().layoutInflater
25 | _binding = DialogOptionsBinding.inflate(inflater)
26 |
27 | val view = binding.root
28 | builder.setTitle(R.string.options)
29 | .setView(view)
30 | .setPositiveButton(R.string.ok, null)
31 | builder.create()
32 |
33 | } ?: throw IllegalStateException("Activity cannot be null")
34 | }
35 |
36 | override fun onStart() {
37 | super.onStart()
38 | val autoModeOption = sharedPreferences.autoMode
39 | when (autoModeOption) {
40 | PrivateDNSUtils.AUTO_MODE_OPTION_OFF -> binding.autoOptionRadioGroup.check(R.id.autoOptionOff)
41 | PrivateDNSUtils.AUTO_MODE_OPTION_AUTO -> binding.autoOptionRadioGroup.check(R.id.autoOptionAuto)
42 | PrivateDNSUtils.AUTO_MODE_OPTION_OFF_AUTO -> binding.autoOptionRadioGroup.check(R.id.autoOptionOffAuto)
43 | PrivateDNSUtils.AUTO_MODE_OPTION_PRIVATE -> binding.autoOptionRadioGroup.check(R.id.autoOptionPrivate)
44 | }
45 | binding.autoOptionRadioGroup.setOnCheckedChangeListener { _, checkedId ->
46 | when (checkedId) {
47 | R.id.autoOptionOff -> sharedPreferences.autoMode = PrivateDNSUtils.AUTO_MODE_OPTION_OFF
48 | R.id.autoOptionAuto -> sharedPreferences.autoMode = PrivateDNSUtils.AUTO_MODE_OPTION_AUTO
49 | R.id.autoOptionOffAuto -> sharedPreferences.autoMode =
50 | PrivateDNSUtils.AUTO_MODE_OPTION_OFF_AUTO
51 |
52 | R.id.autoOptionPrivate -> sharedPreferences.autoMode =
53 | PrivateDNSUtils.AUTO_MODE_OPTION_PRIVATE
54 | }
55 | }
56 |
57 | val requireUnlock = sharedPreferences.requireUnlock
58 | binding.requireUnlockSwitch.isChecked = requireUnlock
59 | binding.requireUnlockSwitch.setOnCheckedChangeListener { _, isChecked ->
60 | sharedPreferences.requireUnlock = isChecked
61 | }
62 | }
63 | }
--------------------------------------------------------------------------------
/app/src/main/res/layout/activity_main.xml:
--------------------------------------------------------------------------------
1 |
2 |
9 |
10 |
17 |
18 |
25 |
26 |
27 |
28 |
33 |
34 |
45 |
46 |
57 |
58 |
68 |
--------------------------------------------------------------------------------
/app/src/main/java/ru/karasevm/privatednstoggle/service/ShortcutService.kt:
--------------------------------------------------------------------------------
1 | package ru.karasevm.privatednstoggle.service
2 |
3 | import android.app.Service
4 | import android.content.Intent
5 | import android.os.IBinder
6 | import android.util.Log
7 | import kotlinx.coroutines.CoroutineScope
8 | import kotlinx.coroutines.Dispatchers
9 | import kotlinx.coroutines.SupervisorJob
10 | import ru.karasevm.privatednstoggle.PrivateDNSApp
11 | import ru.karasevm.privatednstoggle.data.DnsServerRepository
12 | import ru.karasevm.privatednstoggle.util.PreferenceHelper
13 | import ru.karasevm.privatednstoggle.util.PrivateDNSUtils
14 |
15 | class ShortcutService : Service() {
16 |
17 | private val repository: DnsServerRepository by lazy { (application as PrivateDNSApp).repository }
18 | private val job = SupervisorJob()
19 | private val scope = CoroutineScope(Dispatchers.IO + job)
20 |
21 | companion object {
22 | private const val ACTION_DO_CYCLE = "privatednstoggle://do_cycle" // regular cycle
23 | private const val ACTION_SWITCH_MODE = "privatednstoggle://switch_mode" // toggle private and non-private modes
24 | private const val TAG = "ShortcutService"
25 | }
26 |
27 | /**
28 | * Sets the dns mode and shows a toast with the new mode.
29 | *
30 | * @param dnsMode the new dns mode, one of [PrivateDNSUtils.DNS_MODE_OFF],
31 | * [PrivateDNSUtils.DNS_MODE_AUTO], or [PrivateDNSUtils.DNS_MODE_PRIVATE].
32 | * @param dnsProvider the dns provider to set when [dnsMode] is
33 | * [PrivateDNSUtils.DNS_MODE_PRIVATE].
34 | */
35 | private fun setDnsMode(dnsMode: String, dnsProvider: String? = null) {
36 | Log.d(TAG, "setDnsMode: attempting to set dns mode to $dnsMode with provider $dnsProvider")
37 | if (dnsMode == PrivateDNSUtils.DNS_MODE_PRIVATE) {
38 | PrivateDNSUtils.setPrivateProvider(contentResolver, dnsProvider)
39 | }
40 | PrivateDNSUtils.setPrivateMode(contentResolver, dnsMode)
41 | }
42 |
43 | override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
44 | val data = intent?.data.toString()
45 | val sharedPreferences = PreferenceHelper.defaultPreference(this)
46 | Log.d(TAG, "onStartCommand: got intent $data")
47 | if (PrivateDNSUtils.checkForPermission(this)) {
48 | when (data) {
49 | ACTION_DO_CYCLE -> {
50 | PrivateDNSUtils.getNextProvider(
51 | sharedPreferences,
52 | scope,
53 | repository,
54 | contentResolver,
55 | onNext = { dnsMode, dnsProvider ->
56 | setDnsMode(dnsMode, dnsProvider)
57 | })
58 | }
59 |
60 | ACTION_SWITCH_MODE -> {
61 | PrivateDNSUtils.getNextMode(
62 | sharedPreferences,
63 | scope,
64 | repository,
65 | contentResolver,
66 | onNext = { dnsMode, dnsProvider ->
67 | setDnsMode(dnsMode, dnsProvider)
68 | })
69 | }
70 | }
71 | }
72 | return START_NOT_STICKY
73 | }
74 |
75 | override fun onBind(intent: Intent): IBinder? {
76 | return null
77 | }
78 |
79 | }
--------------------------------------------------------------------------------
/app/src/main/res/values-it/strings.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 | L\'indirizzo del server non può essere vuoto
4 | Aggiungi Server
5 | Fatto
6 | Autorizzazione non concessa, segui le istruzioni nell\'app
7 | DNS Privato in modalità automatica
8 | Non permettere di cambiare il server se lo schermo è bloccato
9 | Importazione non riuscita
10 | Dagli appunti
11 | Condividi
12 | Modifica server
13 | Abilitato
14 | Copia negli appunti
15 | Salvataggio non riuscito
16 | Salvato con successo
17 | Come file
18 | Non hai aggiunto nessun Server
19 | Premi + per aggiungerne uno
20 | Autorizzazione concessa, ora è possibile rimuovere l\'autorizzazione su Shizuku
21 | Impossibile ottenere l\'autorizzazione, si consiglia di aggiungerla manualmente
22 | Automatico
23 | Sconosciuto
24 | Aggiungi
25 | Salva
26 | Cancella
27 | Elimina
28 | Sei sicuro di voler eliminare il server?
29 | Elimina
30 | Indirizzo server DNS
31 | Impostazioni
32 | OK
33 | Copiato
34 | Apri l\'app
35 | Informativa sulla privacy
36 | Importa
37 | Esporta
38 | Importato
39 | Private DNS Quick Toggle
40 | Selezionare il server
41 | Etichetta del server DNS (facoltativo)
42 | Scegli quali opzioni includere
43 | Solamente disattivato
44 | Solamente automatico
45 | Disattivato e automatico
46 | Solo DNS Privato
47 | Disattivato
48 | DNS Privato disattivato
49 | Importazione non riuscita, JSON malformato
50 | Da un file
51 |
52 |
--------------------------------------------------------------------------------
/app/src/main/res/values-vi/strings.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 | Thêm máy chủ
4 | Chỉ tự động
5 | Chưa được cấp quyền, hãy kiểm tra ứng dụng để biết cách thực hiện
6 | Tắt
7 | Tự động
8 | Thêm
9 | Chính sách bảo mật
10 | Chọn máy chủ
11 | Hoàn thành
12 | Hủy
13 | Bạn có chắc chắn muốn xóa máy chủ không?
14 | Xoá
15 | Địa chỉ máy chủ DNS
16 | Tùy chọn
17 | OK
18 | Chọn các tùy chọn để đưa vào ô
19 | Chỉ tắt
20 | Tắt và tự động
21 | Mở ứng dụng
22 | DNS cá nhân được thiết lập tự động
23 | Yêu cầu mở khóa thiết bị để thay đổi máy chủ
24 | Tay cầm kéo
25 | Xuất
26 | Đã nhập
27 | Nhập thất bại
28 | Nhập thất bại, JSON bị lỗi
29 | Đã sao chép
30 | Từ tập tin
31 | Chia sẻ
32 | Thành tập tin
33 | Lưu không thành công
34 | Chỉnh sửa máy chủ
35 | Chưa có máy chủ nào
36 | Nhấn vào nút bên dưới để thêm
37 | Đã bật
38 | Chuyển đổi DNS cá nhân
39 | Xoá
40 | Chuyển đổi nhanh DNS cá nhân
41 | Không rõ
42 | Nhãn máy chủ DNS (Không bắt buộc)
43 | Lưu
44 | Địa chỉ máy chủ không được để trống
45 | Chỉ DNS cá nhân
46 | Đã tắt DNS cá nhân
47 | DNS cá nhân được đặt thành %1$s
48 | Nhập
49 | Từ bảng nhớ tạm
50 | Vào bảng nhớ tạm
51 | Đã lưu thành công
52 | Đã được cấp quyền, bạn có thể thu hồi quyền Shizuku ngay bây giờ
53 | Chưa được cấp quyền, vui lòng cấp quyền thủ công
54 |
55 |
--------------------------------------------------------------------------------
/app/src/main/java/ru/karasevm/privatednstoggle/util/ShizukuUtil.kt:
--------------------------------------------------------------------------------
1 | package ru.karasevm.privatednstoggle.util
2 |
3 | import android.Manifest
4 | import android.content.Context
5 | import android.content.pm.IPackageManager
6 | import android.os.Build
7 | import android.os.Process
8 | import android.os.UserHandle
9 | import android.permission.IPermissionManager
10 | import android.util.Log
11 | import org.lsposed.hiddenapibypass.HiddenApiBypass
12 | import rikka.shizuku.ShizukuBinderWrapper
13 | import rikka.shizuku.SystemServiceHelper
14 | import ru.karasevm.privatednstoggle.util.PrivateDNSUtils.checkForPermission
15 |
16 | object ShizukuUtil {
17 |
18 | private const val TAG = "ShizukuUtil"
19 |
20 | /**
21 | * Attempts to grant the WRITE_SECURE_SETTINGS permission using Shizuku.
22 | *
23 | * @param context The context from which the method is called.
24 | * @return True if the permission was granted successfully, false otherwise.
25 | */
26 | fun grantPermissionWithShizuku(context: Context): Boolean {
27 | val packageName = context.packageName
28 | var userId = 0
29 | runCatching {
30 | val userHandle = Process.myUserHandle()
31 | userId = UserHandle::class.java.getMethod("getIdentifier").invoke(userHandle) as? Int ?: 0
32 | }
33 | if (Build.VERSION.SDK_INT >= 31) {
34 | HiddenApiBypass.addHiddenApiExemptions("Landroid/permission")
35 | val binder =
36 | ShizukuBinderWrapper(SystemServiceHelper.getSystemService("permissionmgr"))
37 | val pm = IPermissionManager.Stub.asInterface(binder)
38 | runCatching {
39 | pm.grantRuntimePermission(
40 | packageName,
41 | Manifest.permission.WRITE_SECURE_SETTINGS,
42 | userId
43 | )
44 | }.onFailure { e ->
45 | Log.w(TAG, "Android 12 method failed: ", e)
46 | runCatching {
47 | pm.grantRuntimePermission(
48 | packageName,
49 | Manifest.permission.WRITE_SECURE_SETTINGS,
50 | 0,
51 | userId
52 | )
53 | }.onFailure { e ->
54 | Log.w(TAG, "Android 14 QPR2 method failed: ", e)
55 | runCatching {
56 | pm.grantRuntimePermission(
57 | packageName,
58 | Manifest.permission.WRITE_SECURE_SETTINGS,
59 | "default:0",
60 | userId
61 | )
62 | }.onFailure { e ->
63 | Log.w(TAG, "Android 14 QPR3 method failed: ", e)
64 | }
65 | }
66 | }
67 | } else {
68 | val binder = ShizukuBinderWrapper(SystemServiceHelper.getSystemService("package"))
69 | val pm = IPackageManager.Stub.asInterface(binder)
70 | runCatching {
71 | pm.grantRuntimePermission(
72 | packageName,
73 | Manifest.permission.WRITE_SECURE_SETTINGS,
74 | userId
75 | )
76 | }.onFailure { e ->
77 | Log.w(TAG, "Android <12 method failed: ", e)
78 | }
79 | }
80 | return checkForPermission(context)
81 | }
82 | }
--------------------------------------------------------------------------------
/app/src/main/res/values-sr/strings.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 | Izaberi opcije koje će biti u prečici
4 | Isključeno i automatski
5 | Private DNS postavljen na %1$s
6 | Davanje dozvole neuspešno, molimo dodaj je ručno
7 | Dodirni dugme ispod da dodaš
8 | Private DNS Toggle
9 | Isključeno
10 | Automatski
11 | Nepoznato
12 | Dodaj server
13 | Sačuvaj
14 | Politika privatnosti
15 | Izaberi server
16 | Gotovo
17 | Otkaži
18 | Obriši
19 | DNS server Naziv (Opciono)
20 | Opcije
21 | OK
22 | Samo isključeno
23 | Samo automatski
24 | Samo Private DNS
25 | Otvori aplikaciju
26 | Private DNS isključen
27 | Private DNS postavljen na automatski
28 | Traži otključavanje uređaja za promenu servera
29 | Ručica za prevlačenje
30 | Uvezi
31 | Uveženo
32 | Uvoz neuspešan, deformisan JSON
33 | Kopirano
34 | Iz datoteke
35 | U clipboard
36 | Podeli
37 | Čuvanje neuspešno
38 | Uspešno sačuvano
39 | Izmeni server
40 | Server nije dodat
41 | Omogućeno
42 | Dozvola odobrena, možeš povući dozvolu za Shizuku
43 | Private DNS Quick Toggle
44 | Da li ste sigurni da želite da obrišete server?
45 | Dozvola nije omogućena, pogledaj u aplikaciji kako se to radi
46 | Dodaj
47 | Izvezi
48 | Obriši
49 | Adresa servera ne može biti prazna
50 | DNS server adresa
51 | U datoteku
52 | Uvoz neuspešan
53 | Iz clipboarda
54 |
55 |
--------------------------------------------------------------------------------
/app/src/main/res/values-tr/strings.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 | Sunucuyu silmek istediğinizden emin misiniz?
4 | Sadece kapalı
5 | Sunucuyu değiştirmek için cihazın kilidini açmanız gerekiyor
6 | Oto
7 | Bilinmeyen
8 | Tamam
9 | İptal
10 | DNS sunucusu Etiketi (İsteğe bağlı)
11 | Seçenekler
12 | OK
13 | Sadece oto
14 | Kapalı ve oto
15 | Özel DNS kapandı
16 | Uygulamayı açınız
17 | İçe aktar
18 | Dışa aktar
19 | içe aktarıldı
20 | Dosyaya
21 | Kaydetme başarısız oldu
22 | Hiç Sunucu Eklenmedi
23 | Eklemek için aşağıdaki düğmeye dokunun
24 | İzin alınamadı, lütfen manuel olarak verin
25 | Karoya hangi seçeneklerin dahil edileceğini seçiniz
26 | Kapalı
27 | Gizlilik Politikası
28 | Sil
29 | Sunucu adresi boş olamaz
30 | Sil
31 | DNS sunucu adresi
32 | tutacağı sürükle
33 | Ekle
34 | Sunucu Ekleyiniz
35 | İzin verilmedi, nasıl yapıldığını görmek için uygulamayı kontrol ediniz
36 | İçe aktarma başarısız oldu, hatalı biçimlendirilmiş JSON
37 | Özel DNS Hızlı Geçiş
38 | İzin verildi, şimdi Shizuku iznini iptal edebilirsiniz
39 | Aktarma başarısız
40 | Panodan
41 | Başarıyla kaydedildi
42 | Kaydet
43 | Özel DNS otomatik olarak ayarlandı
44 | Özel DNS %1$s olarak ayarlandı
45 | Sadece Özel DNS
46 | Sunucuyu Seçin
47 | Kopyalandı
48 | Sunucuyu düzenle
49 | Etkin
50 | Özel DNS Geçişi
51 | Dosyadan
52 | Panoya
53 | Paylaş
54 |
55 |
--------------------------------------------------------------------------------
/app/src/main/res/values-uk/strings.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 | Перемикач приватного DNS
4 | Невідомо
5 | Обрати сервер
6 | Відмінити
7 | Не додано жодного серверу
8 | Адреса DNS серверу
9 | Гаразд
10 | Приватний DNS автоматично
11 | Імпортувати
12 | Збереження провалено
13 | Скопійовано
14 | В буфер обміну
15 | Тапніть на кнопку нижче щоб додати один
16 | В Файл
17 | Імпорт провалено, некоректний JSON
18 | Вимк.
19 | Приватний DNS %1$s
20 | З файлу
21 | Позначка DNS серверу (Опціонально)
22 | Додати сервер
23 | Готово
24 | Швидке перемикання приватного DNS
25 | Редагувати сервер
26 | Видалити
27 | Опції
28 | Збережено успішно
29 | Дозвіл не надано, перегляньте додаток для інструкції як це зробити
30 | Автоматично
31 | Додати
32 | Зберегти
33 | Політика Приватності
34 | Ви впевнені що хочете видалити сервер?
35 | Видалити
36 | Адреса серверу не може бути пустою
37 | Оберіть які опції додати на плитку
38 | Лише вимкнено
39 | Лише автоматично
40 | Вимкнено і автоматично
41 | Лише приватний DNS
42 | Відкрити додаток
43 | Приватний DNS вимкнено
44 | Вимагати розблокування пристрою для зміни сервера
45 | Ручка перетягування
46 | Експортувати
47 | Імпортовано
48 | Імпорт провалено
49 | З буферу обміну
50 | Поділитись
51 | Увімкнено
52 | Дозвіл надано, ви можете відкликати дозвіл Shizuku зараз
53 | Не вдалося отримати дозвіл, надайте його вручну
54 |
55 |
--------------------------------------------------------------------------------
/app/src/main/res/values/strings.xml:
--------------------------------------------------------------------------------
1 |
2 | Private DNS Quick Toggle
3 | Private DNS Toggle
4 | Permission not granted, check app to see how to do it
5 | Off
6 | Auto
7 | Unknown
8 | Add Server
9 | Add
10 | Save
11 | Privacy Policy
12 | Select Server
13 | Done
14 | Cancel
15 | Delete
16 | Are you sure you want to delete server?
17 | Delete
18 | Server address cannot be empty
19 | DNS server Label (Optional)
20 | DNS server address
21 | Options
22 | OK
23 | Choose which options to include in the tile
24 | Only off
25 | Only auto
26 | Off and auto
27 | Only Private DNS
28 | Open app
29 | Private DNS turned off
30 | Private DNS set to auto
31 | Private DNS set to %1$s
32 | Require unlocking the device to change server
33 | Drag handle
34 | Import
35 | Export
36 | Imported
37 | Import failed
38 | Import failed, malformed JSON
39 | Copied
40 | From file
41 | From clipboard
42 | To clipboard
43 | Share
44 | To file
45 | Saving failed
46 | Saved successfully
47 | Edit server
48 | No Servers Added
49 | Tap on the button below to add one
50 | Enabled
51 | Permission granted, you can revoke the Shizuku permission now
52 | Failed to acquire permission, please grant it manually
53 | Next server/mode
54 | Switch mode
55 | Open-source licenses
56 |
--------------------------------------------------------------------------------
/app/src/main/res/values-hu/strings.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 | Privát DNS Gyorskapcsoló
4 | Privát DNS Kapcsoló
5 | Nincs engedély megadva, nézd meg az alkalmazásban, hogyan adhatod meg
6 | Ki
7 | Automatikus
8 | Ismeretlen
9 | Szerver hozzáadása
10 | Hozzáadás
11 | Mentés
12 | Adatvédelmi irányelvek
13 | Szerver kiválasztása
14 | Kész
15 | Mégse
16 | Törlés
17 | Biztosan törölni szeretnéd a szervert?
18 | Törlés
19 | A szervercím nem lehet üres
20 | DNS szerver neve (opcionális)
21 | DNS szerver címe
22 | Beállítások
23 | OK
24 | Válaszd ki, mely opciók jelenjenek meg a csempén
25 | Csak ki
26 | Csak automatikus
27 | Ki és automatikus
28 | Csak Privát DNS
29 | Alkalmazás megnyitása
30 | Privát DNS kikapcsolva
31 | Privát DNS automatikus módra állítva
32 | Privát DNS beállítva: %1$s
33 | Eszköz feloldása szükséges a szerver módosításához
34 | Húzási fogantyú
35 | Importálás
36 | Exportálás
37 | Importálva
38 | Importálás sikertelen
39 | Importálás sikertelen, hibás JSON
40 | Másolva
41 | Fájlból
42 | Vágólapról
43 | Vágólapra
44 | Megosztás
45 | Fájlba
46 | Mentés sikertelen
47 | Sikeresen mentve
48 | Szerver szerkesztése
49 | Nincsenek szerverek hozzáadva
50 | Koppints az alábbi gombra, hogy hozzáadj egyet
51 | Engedélyezve
52 | Engedélyt megadva, most már visszavonhatja a Shizuku engedélyt
53 | Nem sikerült engedélyt szerezni, kérjük, adja meg manuálisan
54 |
55 |
--------------------------------------------------------------------------------
/app/src/main/res/values-pl/strings.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 | Usuń
4 | Zapisz
5 | Polityka prywatności
6 | Wybierz serwer
7 | Anuluj
8 | Adres serwera DNS
9 | OK
10 | Otwórz aplikację
11 | Importuj
12 | Eksportuj
13 | Skopiowano
14 | Z pliku
15 | Ze schowka
16 | Do schowka
17 | Udostępnij
18 | Do pliku
19 | Edytuj serwer
20 | Brak dodanych serwerów
21 | Włączone
22 | Dodaj serwer
23 | Dodaj
24 | Usuń
25 | Nieznane
26 | Gotowe
27 | Opcje
28 | Importowanie nie powiodło się
29 | Automatycznie
30 | Wyłącz
31 | Zaimportowano
32 | Adres serwera nie może być pusty
33 | Import nie powiódł się, zniekształcony plik JSON
34 | Zapisano pomyślnie
35 | Czy na pewno chcesz usunąć serwer?
36 | Private DNS Quick Toggle
37 | Przełącznik prywatnego DNS
38 | Nieprzydzielono uprawnienia, sprawdź w aplikacji, w jaki sposób można to zrobić
39 | Opis serwera DNS (opcjonalnie)
40 | Wybierz opcje, które będą dostępne w kafelku
41 | Tylko wyłączenie
42 | Tylko automatycznie
43 | Wyłączenie i automatycznie
44 | Tylko prywatny DNS
45 | Prywatny DNS zmieniony na automatyczny
46 | Prywatny DNS zmieniony na %1$s
47 | Wymagaj odblokowania urządzenia do zmiany serwera
48 | Wyłączono Prywatny DNS
49 | Przeciągnij
50 | Zapisywanie nie powiodło się
51 | Kliknij na poniższy przycisk, aby dodać nowy
52 | Udzielono zezwolenia, możesz teraz cofnąć zezwolenie w Shizuku
53 | Uzyskanie uprawnień nie powiodło się, udziel ich ręcznie
54 |
55 |
--------------------------------------------------------------------------------
/app/src/main/AndroidManifest.xml:
--------------------------------------------------------------------------------
1 |
2 |
4 |
5 |
8 |
9 |
16 |
20 |
21 |
27 |
28 |
34 |
35 |
36 |
37 |
38 |
41 |
42 |
47 |
48 |
49 |
50 |
51 |
56 |
57 |
58 |
59 |
60 |
61 |
62 |
63 |
64 |
65 |
66 |
70 |
71 |
72 |
78 |
79 |
80 |
81 |
82 |
83 |
84 |
--------------------------------------------------------------------------------
/app/src/main/res/values-pt-rBR/strings.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 | Importado
4 | Permissão não concedida, verifique o app para saber como prosseguir
5 | Alteração de DNS privado
6 | Cancelar
7 | Adicionar servidor
8 | Endereço do servidor DNS
9 | Escolha opções disponível em atalho
10 | Alteração de DNS privado
11 | Desativado
12 | Automático
13 | Indeterminado
14 | Adicionar
15 | Salvar
16 | Política de privacidade
17 | Concluído
18 | Apagar
19 | Tem certeza de que quer apagar o servidor?
20 | Apagar
21 | O endereço do servidor não pode estar em branco
22 | Identificação do servidor DNS (opcional)
23 | Opções
24 | Ok
25 | Somente desativado
26 | Desativado e automático
27 | Somente DNS privado
28 | Abrir app
29 | DNS privado desativado
30 | DNS privado definido para automático
31 | DNS privado definido para %1$s
32 | Arrastre
33 | Importar
34 | Exportar
35 | Falha na importação, JSON malformado
36 | Copiado
37 | Da memória
38 | Compartilhar
39 | Para arquivo
40 | Salvo com sucesso
41 | Editar servidor
42 | Nenhum servidor adicionado
43 | Toque no botão abaixo para adicionar
44 | Ativado
45 | Escolha servidor
46 | Para memória
47 | De arquivo
48 | Falha ao importar
49 | Falha ao salvar
50 | Necessário desbloquear o dispositivo para alterar servidor
51 | Somente automático
52 | Falha ao obter a permissão. Tente conceder manualmente
53 | Permissão concedida, você pode revogar a permissão do Shizuku agora
54 |
55 |
--------------------------------------------------------------------------------
/app/src/main/java/ru/karasevm/privatednstoggle/data/DnsServerDao.kt:
--------------------------------------------------------------------------------
1 | package ru.karasevm.privatednstoggle.data
2 |
3 | import androidx.room.Dao
4 | import androidx.room.Query
5 | import androidx.room.Transaction
6 | import kotlinx.coroutines.flow.Flow
7 | import ru.karasevm.privatednstoggle.model.DnsServer
8 |
9 | @Dao
10 | interface DnsServerDao {
11 |
12 | @Query("SELECT * FROM dns_servers ORDER BY sortOrder ASC")
13 | fun getAll(): Flow>
14 |
15 | @Query("SELECT * FROM dns_servers WHERE enabled = 1 ORDER BY sortOrder ASC LIMIT 1")
16 | suspend fun getFirstEnabled(): DnsServer
17 |
18 | @Query("SELECT * FROM dns_servers WHERE server = :server LIMIT 1")
19 | suspend fun getFirstByServer(server: String): DnsServer?
20 |
21 | @Query("SELECT * FROM dns_servers WHERE id = :id")
22 | suspend fun getById(id: Int): DnsServer?
23 |
24 | @Query("SELECT * FROM dns_servers " +
25 | "WHERE sortOrder > (SELECT sortOrder FROM dns_servers WHERE server = :server) AND enabled = 1 " +
26 | "ORDER BY sortOrder ASC " +
27 | "LIMIT 1")
28 | suspend fun getNextEnabledByServer(server: String): DnsServer?
29 |
30 | @Query("DELETE FROM dns_servers")
31 | suspend fun deleteAll()
32 |
33 | @Query("DELETE FROM dns_servers WHERE id = :id")
34 | suspend fun deleteById(id: Int)
35 |
36 | @Query("UPDATE dns_servers SET sortOrder = sortOrder + 1 " +
37 | "WHERE sortOrder >= :startSortOrder AND sortOrder <= :endSortOrder")
38 | suspend fun incrementSortOrder(startSortOrder: Int, endSortOrder: Int = Int.MAX_VALUE)
39 |
40 | @Query("UPDATE dns_servers SET sortOrder = sortOrder - 1 " +
41 | "WHERE sortOrder >= :startSortOrder AND sortOrder <= :endSortOrder")
42 | suspend fun decrementSortOrder(startSortOrder: Int, endSortOrder: Int = Int.MAX_VALUE)
43 |
44 | @Query("UPDATE dns_servers SET sortOrder = sortOrder - 1 " +
45 | "WHERE sortOrder > (SELECT sortOrder FROM dns_servers WHERE id = :id)")
46 | suspend fun decrementSortOrderById(id: Int)
47 |
48 | @Transaction
49 | suspend fun deleteAndDecrement(id: Int) {
50 | decrementSortOrderById(id)
51 | deleteById(id)
52 | }
53 |
54 | @Query("UPDATE dns_servers SET label = :label WHERE id = :id")
55 | suspend fun updateLabel(id: Int, label: String)
56 |
57 | @Query("UPDATE dns_servers SET server = :server WHERE id = :id")
58 | suspend fun updateServer(id: Int, server: String)
59 |
60 | @Query("UPDATE dns_servers " +
61 | "SET server = COALESCE(:server, server), " +
62 | " label = COALESCE(:label, label), " +
63 | " sortOrder = COALESCE(:sortOrder, sortOrder), " +
64 | " enabled = COALESCE(:enabled, enabled) " +
65 | "WHERE id = :id")
66 | suspend fun update(id: Int, server: String?, label: String?, sortOrder: Int?, enabled: Boolean?)
67 |
68 | @Transaction
69 | suspend fun moveUp(sortOrder: Int, newSortOrder: Int, id: Int){
70 | incrementSortOrder(newSortOrder, sortOrder)
71 | update(id, null, null, newSortOrder, null)
72 | }
73 |
74 | @Transaction
75 | suspend fun moveDown(sortOrder: Int, newSortOrder: Int, id: Int){
76 | decrementSortOrder(sortOrder, newSortOrder)
77 | update(id, null, null, newSortOrder, null)
78 | }
79 |
80 | @Query("INSERT INTO dns_servers(server, label, sortOrder, enabled) " +
81 | "VALUES(:server, :label, COALESCE((SELECT MAX(sortOrder) + 1 FROM dns_servers), 0), :enabled)")
82 | suspend fun insert(server: String, label: String, enabled: Boolean)
83 |
84 | }
--------------------------------------------------------------------------------
/app/src/main/res/values-fr/strings.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 | Terminé
4 | Private DNS Quick Toggle
5 | Inconnu
6 | Éteint
7 | Ajouter un serveur
8 | Ajouter
9 | Politique de confidentialité
10 | Êtes-vous sûr de vouloir supprimer le serveur ?
11 | Supprimer
12 | L\'adresse du serveur ne peut pas être vide
13 | Étiquette du serveur DNS (Facultatif)
14 | Adresse du serveur DNS
15 | Options
16 | OK
17 | Seulement éteint
18 | Seulement automatique
19 | Éteint et automatique
20 | Automatique
21 | Commutateur de DNS privé
22 | Enregistrer
23 | Autorisation non accordée, vérifiez les instructions dans l\'application
24 | Sélectionner le serveur
25 | Définissez les options à inclure
26 | DNS privé seulement
27 | Ouvrir l\'application
28 | DNS privé éteint
29 | DNS privé réglé sur automatique
30 | DNS privé réglé sur %1$s
31 | Requiert le déverrouillage de l\'appareil pour changer de serveur
32 | Activé
33 | À partir du fichier
34 | Aucun serveur ajouté
35 | Copié
36 | Éditer le serveur
37 | Échec de la sauvegarde
38 | Appuyez sur le bouton ci-dessous pour en ajouter un
39 | Impossible d\'obtenir l\'autorisation, veuillez l\'accorder manuellement
40 | Autorisation accordée, vous pouvez désormais révoquer l\'autorisation Shizuku
41 | À partir du presse-papier
42 | Vers le presse-papier
43 | Partager
44 | Vers le fichier
45 | Sauvegarde réussie
46 | Échec de l\'importation
47 | Échec de l\'importation, le fichier JSON est incorrect
48 | Importé
49 | Exporter
50 | Importer
51 | Poignée
52 | Supprimer
53 | Annuler
54 |
55 |
--------------------------------------------------------------------------------
/app/src/main/res/values-ru/strings.xml:
--------------------------------------------------------------------------------
1 |
2 | Private DNS Quick Toggle
3 | Переключить частный DNS
4 | Разрешение не предоставлено, проверьте приложение для получения информации
5 | Выкл
6 | Авто
7 | Неизвестно
8 | Добавить сервер
9 | Добавить
10 | Сохранить
11 | Политика конфиденциальности
12 | Выбрать сервер
13 | Готово
14 | Отмена
15 | Удалить
16 | Вы уверены, что хотите удалить сервер?
17 | Удалить
18 | Адрес сервера не может быть пустым
19 | Название DNS сервера (необязательно)
20 | Адрес DNS сервера
21 | Опции
22 | OK
23 | Выберите, какие опции включить в плитке
24 | Только \"Выкл\"
25 | Только \"Авто\"
26 | \"Выкл\" и \"Авто\"
27 | Только частный DNS
28 | Открыть приложение
29 | Частный DNS выключен
30 | Частный DNS установлен на "Авто"
31 | Частный DNS установлен на %1$s
32 | Смена сервера требует разблокировки устройства
33 | Ручка перетаскивания
34 | Импорт
35 | Экспорт
36 | Успешно импортировано
37 | Импорт не удался
38 | Импорт не удался, некорректный JSON
39 | Скопировано
40 | Из файла
41 | Из буфера обмена
42 | В буфер обмена
43 | Поделиться
44 | В файл
45 | Сохранение не удалось
46 | Успешно сохранено
47 | Редактировать сервер
48 | Нет доступных серверов
49 | Нажмите на кнопку ниже, чтобы добавить сервер
50 | Включён
51 | Разрешение получено, можно отозвать авторизацию Shizuku
52 | Не удалось получить разрешение, предоставьте его вручную
53 | Следующий сервер/режим
54 | Переключить режим
55 | Лицензии открытого ПО
56 |
--------------------------------------------------------------------------------
/app/src/main/res/values-ta/strings.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 | சரி
4 | தனியார் டி.என்.எச் விரைவாக மாற்று
5 | தனியார் டி.என் கள் மாறுகின்றன
6 | இசைவு வழங்கப்படவில்லை, அதை எப்படி செய்வது என்று பார்க்க பயன்பாட்டை சரிபார்க்கவும்
7 | அணை
8 | தானி
9 | தெரியவில்லை
10 | சேவையகத்தைச் சேர்க்கவும்
11 | கூட்டு
12 | சேமி
13 | தனியுரிமைக் கொள்கை
14 | சேவையகத்தைத் தேர்ந்தெடுக்கவும்
15 | முடிந்தது
16 | ரத்துசெய்
17 | நீக்கு
18 | சேவையகத்தை நீக்க விரும்புகிறீர்களா?
19 | நீக்கு
20 | டிஎன்எச் சேவையக முகவரி
21 | விருப்பங்கள்
22 | ஓடுகளில் எந்த விருப்பங்களைச் சேர்க்க வேண்டும் என்பதைத் தேர்வுசெய்க
23 | மட்டுமே
24 | ஆட்டோ மட்டுமே
25 | ஆஃப் மற்றும் ஆட்டோ
26 | தனியார் டி.என்.எச் மட்டுமே
27 | திறந்த பயன்பாடு
28 | தனியார் டி.என்.எச் அணைக்கப்பட்டது
29 | தனியார் டி.என்.எச் ஆட்டோவாக அமைக்கப்பட்டுள்ளது
30 | தனியார் டி.என்.எச் %1$s என அமைக்கப்பட்டுள்ளது
31 | சேவையகத்தை மாற்ற சாதனத்தைத் திறக்க வேண்டும்
32 | இழுவை கைப்பிடி
33 | இறக்குமதி
34 | ஏற்றுமதி
35 | இறக்குமதி செய்யப்பட்டது
36 | இறக்குமதி தோல்வியடைந்தது
37 | இறக்குமதி தோல்வியுற்றது, தவறாக சாதொபொகு
38 | நகலெடுக்கப்பட்டது
39 | கோப்பிலிருந்து
40 | கிளிப்போர்டிலிருந்து
41 | இடைநிலைப்பலகைக்கு
42 | பங்கு
43 | தாக்கல் செய்ய
44 | சேமிப்பு தோல்வியடைந்தது
45 | வெற்றிகரமாக சேமிக்கப்பட்டது
46 | சேவையகத்தைத் திருத்து
47 | சேவையகங்கள் எதுவும் சேர்க்கப்படவில்லை
48 | ஒன்றைச் சேர்க்க கீழே உள்ள பொத்தானைத் தட்டவும்
49 | இயக்கப்பட்டது
50 | இசைவு வழங்கப்பட்டது, நீங்கள் இப்போது சிசுகு அனுமதியை ரத்து செய்யலாம்
51 | இசைவு பெறுவதில் தோல்வி, தயவுசெய்து அதை கைமுறையாக வழங்கவும்
52 | சேவையக முகவரி காலியாக இருக்க முடியாது
53 | டிஎன்எச் சேவையக சிட்டை (விரும்பினால்)
54 |
55 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | [](https://github.com/karasevm/PrivateDNSAndroid/releases/latest)
2 | [](https://github.com/karasevm/PrivateDNSAndroid/releases/latest)
3 | [](https://apt.izzysoft.de/fdroid/index/apk/ru.karasevm.privatednstoggle)
4 | [](https://hosted.weblate.org/engage/privatednsandroid/)
5 |
6 | # Private DNS Quick Toggle
7 | 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
8 | a single tap.
9 |
10 | 
11 |
12 | ## Installation
13 |
14 | ### IzzyOnDroid (Recommended, will enable auto-updates on Android 12+)
15 |
16 | 1. Install an F-droid client such as [Droidify](https://droidify.eu.org/download) or the [official F-Droid client](https://f-droid.org/)
17 | 2. Verify that [IzzyOnDroid repo](https://apt.izzysoft.de/fdroid/repo?fingerprint=3BF0D6ABFEAE2F401707B6D966BE743BF0EEE49C2561B9BA39073711F628937A) is added and enabled
18 | 3. Search for the full app name "Private DNS Quick Toggle" and install it
19 |
20 | ### GitHub Releases
21 | Get the latest apk on the [releases page](https://github.com/karasevm/PrivateDNSAndroid/releases/latest)
22 |
23 | ## Activation
24 | To change the system DNS options the app requires `android.permission.WRITE_SECURE_SETTINGS` permission. There are multiple ways to provide it.
25 |
26 | ### Automatic (Shizuku)
27 | 1. Install and start [Shizuku](https://shizuku.rikka.app/).
28 | 2. Start the app and allow Shizuku access when prompted.
29 |
30 | ### Manual
31 | For the app to work properly you'll need to provide it permissions via ADB:
32 |
33 | 1. Get to your PC and download platform tools from google [here](https://developer.android.com/studio/releases/platform-tools).
34 | 2. Extract the tools, and open terminal in the same directory ([Windows guide](https://youtu.be/6vVFmOcIADg?t=38), [macos guide](https://www.howtogeek.com/210147/how-to-open-terminal-in-the-current-os-x-finder-location/)).
35 | 3. Turn on USB Debugging on your phone (This may require different steps, for Xiaomi you also have to enable `USB Debugging (Security settings)`, but generally [this video guide](https://youtu.be/Ucs34BkfPB0?t=29) should work on most phones)
36 | 4. Connect your phone to your PC
37 | 5. Run this command in the terminal
38 |
39 | ```
40 | ./adb shell pm grant ru.karasevm.privatednstoggle android.permission.WRITE_SECURE_SETTINGS
41 | ```
42 |
43 | 6. That's it, you should have the app installed.
44 |
45 | ## Contributing
46 |
47 | ### Translation
48 | 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/).
49 | #### Translation status
50 |
51 |
52 |
53 |
54 | ### Code
55 | If you want to contribute code please try to adhere to the following guidelines:
56 | - Include javadoc comments for all the public methods you add
57 | - Keep the code neatly formatted, you can you the built-in Android Studio formatter
58 | - Please describe what your code does and how does it do that when sending a PR
59 | - Before sending a PR please test your change on the oldest and latest supported Android versions (9 and 14 at the time of writing)
60 |
--------------------------------------------------------------------------------
/app/build.gradle.kts:
--------------------------------------------------------------------------------
1 | import org.jetbrains.kotlin.gradle.dsl.JvmTarget
2 |
3 | plugins {
4 | id("com.android.application")
5 | id("kotlin-android")
6 | id("com.google.devtools.ksp")
7 | id("org.jetbrains.kotlin.plugin.serialization")
8 | id("com.mikepenz.aboutlibraries.plugin")
9 | id("org.jetbrains.kotlin.plugin.compose")
10 | }
11 |
12 | android {
13 | compileSdk = 36
14 | androidResources {
15 | generateLocaleConfig = true
16 | }
17 | defaultConfig {
18 | applicationId = "ru.karasevm.privatednstoggle"
19 | versionCode = 19
20 | versionName = "1.11.0"
21 |
22 | testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
23 | targetSdk = 36
24 | minSdk = 28
25 | }
26 | buildFeatures {
27 | viewBinding = true
28 | buildConfig = true
29 | compose = true
30 | }
31 | buildTypes {
32 | release {
33 | isMinifyEnabled = true
34 | isShrinkResources = true
35 | proguardFiles(
36 | getDefaultProguardFile("proguard-android-optimize.txt"),
37 | "proguard-rules.pro"
38 | )
39 | manifestPlaceholders["appIcon"] = "@mipmap/ic_launcher"
40 | }
41 | debug {
42 | applicationIdSuffix = ".dev"
43 | manifestPlaceholders["appIcon"] = "@mipmap/ic_launcher_debug"
44 | }
45 | }
46 | compileOptions {
47 | sourceCompatibility = JavaVersion.VERSION_17
48 | targetCompatibility = JavaVersion.VERSION_17
49 | }
50 |
51 | dependenciesInfo {
52 | // Disables dependency metadata when building APKs.
53 | includeInApk = false
54 | // Disables dependency metadata when building Android App Bundles.
55 | includeInBundle = false
56 | }
57 | namespace = "ru.karasevm.privatednstoggle"
58 | }
59 |
60 | kotlin {
61 | compilerOptions {
62 | jvmTarget = JvmTarget.JVM_17
63 | }
64 | }
65 |
66 | dependencies {
67 | implementation("androidx.core:core-ktx:1.16.0")
68 | implementation("androidx.appcompat:appcompat:1.7.1")
69 | implementation("androidx.recyclerview:recyclerview:1.4.0")
70 | implementation("androidx.activity:activity-ktx:1.10.1")
71 | implementation("androidx.fragment:fragment-ktx:1.8.8")
72 | implementation("com.google.android.material:material:1.12.0")
73 | implementation("androidx.constraintlayout:constraintlayout:2.2.1")
74 | implementation("com.google.guava:guava:33.4.8-android")
75 | implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.9.0")
76 |
77 | val shizukuVersion = "13.1.5"
78 | implementation("dev.rikka.shizuku:api:$shizukuVersion")
79 | implementation("dev.rikka.shizuku:provider:$shizukuVersion")
80 | compileOnly("dev.rikka.hidden:stub:4.4.0")
81 |
82 | implementation("org.lsposed.hiddenapibypass:hiddenapibypass:6.1")
83 |
84 | // Room components
85 | val roomVersion = "2.7.2"
86 | implementation("androidx.room:room-ktx:$roomVersion")
87 | ksp("androidx.room:room-compiler:$roomVersion")
88 | androidTestImplementation("androidx.room:room-testing:$roomVersion")
89 |
90 | // Lifecycle components
91 | val lifecycleVersion = "2.9.2"
92 | implementation("androidx.lifecycle:lifecycle-viewmodel-ktx:$lifecycleVersion")
93 | implementation("androidx.lifecycle:lifecycle-livedata-ktx:$lifecycleVersion")
94 | implementation("androidx.lifecycle:lifecycle-common-java8:$lifecycleVersion")
95 |
96 | // Compose
97 | val composeBom = platform("androidx.compose:compose-bom:2025.05.00")
98 | implementation(composeBom)
99 | androidTestImplementation(composeBom)
100 | implementation("androidx.compose.material3:material3")
101 | implementation("androidx.compose.ui:ui-tooling-preview")
102 | debugImplementation("androidx.compose.ui:ui-tooling")
103 | implementation("androidx.activity:activity-compose:1.10.1")
104 | implementation("androidx.lifecycle:lifecycle-viewmodel-compose:$lifecycleVersion")
105 | implementation("androidx.compose.runtime:runtime-livedata")
106 |
107 |
108 | val latestAboutLibsRelease = "12.2.4"
109 | implementation("com.mikepenz:aboutlibraries-core:$latestAboutLibsRelease")
110 | implementation("com.mikepenz:aboutlibraries-compose-m3:${latestAboutLibsRelease}")
111 |
112 | testImplementation("junit:junit:4.13.2")
113 | androidTestImplementation("androidx.test.ext:junit:1.3.0")
114 | androidTestImplementation("androidx.test.espresso:espresso-core:3.7.0")
115 | }
--------------------------------------------------------------------------------
/app/src/main/java/ru/karasevm/privatednstoggle/ui/ServerListRecyclerAdapter.kt:
--------------------------------------------------------------------------------
1 | package ru.karasevm.privatednstoggle.ui
2 |
3 | import android.annotation.SuppressLint
4 | import android.view.LayoutInflater
5 | import android.view.MotionEvent
6 | import android.view.View
7 | import android.view.ViewGroup
8 | import android.widget.ImageView
9 | import android.widget.TextView
10 | import androidx.recyclerview.widget.DiffUtil
11 | import androidx.recyclerview.widget.RecyclerView
12 | import ru.karasevm.privatednstoggle.R
13 | import ru.karasevm.privatednstoggle.model.DnsServer
14 |
15 |
16 | class ServerListRecyclerAdapter(private val showDragHandle: Boolean) :
17 | RecyclerView.Adapter() {
18 |
19 |
20 | override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): DnsServerViewHolder {
21 | val view =
22 | LayoutInflater.from(parent.context).inflate(R.layout.recyclerview_row, parent, false)
23 | val vh = DnsServerViewHolder(view)
24 | return vh
25 | }
26 |
27 | override fun getItemCount(): Int {
28 | return items.size
29 | }
30 |
31 | var onItemClick: ((Int) -> Unit)? = null
32 | var onDragStart: ((DnsServerViewHolder) -> Unit)? = null
33 | private var items: MutableList = mutableListOf()
34 |
35 | @SuppressLint("ClickableViewAccessibility")
36 | override fun onBindViewHolder(holder: DnsServerViewHolder, position: Int) {
37 | val item = items[position]
38 | if (item.label.isNotEmpty()) {
39 | holder.labelTextView.text = item.label
40 | holder.labelTextView.visibility = View.VISIBLE
41 | } else {
42 | holder.labelTextView.visibility = View.GONE
43 | }
44 | holder.serverTextView.text = item.server
45 | holder.id = item.id
46 | if (item.enabled) {
47 | holder.labelTextView.alpha = 1f
48 | holder.serverTextView.alpha = 1f
49 | } else {
50 | holder.labelTextView.alpha = 0.5f
51 | holder.serverTextView.alpha = 0.5f
52 | }
53 | if (showDragHandle) {
54 | holder.dragHandle.visibility = View.VISIBLE
55 | holder.dragHandle.setOnTouchListener { _, event ->
56 | if (event.actionMasked == MotionEvent.ACTION_DOWN) {
57 | onDragStart?.invoke(holder)
58 | }
59 | return@setOnTouchListener true
60 | }
61 | }
62 | }
63 |
64 | /**
65 | * Update server position in memory
66 | * @param fromPosition old position
67 | * @param toPosition new position
68 | */
69 | fun onItemMove(fromPosition: Int, toPosition: Int) {
70 | items.add(toPosition, items.removeAt(fromPosition))
71 | notifyItemMoved(fromPosition, toPosition)
72 | }
73 |
74 | class DiffCallback(
75 | private val oldList: List, private var newList: List
76 | ) : DiffUtil.Callback() {
77 |
78 | override fun getOldListSize(): Int {
79 | return oldList.size
80 | }
81 |
82 | override fun getNewListSize(): Int {
83 | return newList.size
84 | }
85 |
86 | override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean {
87 | val oldItem = oldList[oldItemPosition]
88 | val newItem = newList[newItemPosition]
89 | return oldItem.id == newItem.id
90 | }
91 |
92 | override fun areContentsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean {
93 | val oldItem = oldList[oldItemPosition]
94 | val newItem = newList[newItemPosition]
95 | return oldItem.server == newItem.server && oldItem.label == newItem.label && oldItem.enabled == newItem.enabled
96 | }
97 | }
98 |
99 | /**
100 | * Submit list to adapter
101 | * @param list list to submit
102 | */
103 | fun submitList(list: List) {
104 | val diffCallback = DiffCallback(items, list)
105 | val diffResult = DiffUtil.calculateDiff(diffCallback)
106 | items.clear()
107 | items.addAll(list)
108 | diffResult.dispatchUpdatesTo(this)
109 | }
110 |
111 | inner class DnsServerViewHolder(view: View) : RecyclerView.ViewHolder(view) {
112 | val labelTextView: TextView = view.findViewById(R.id.labelTextView)
113 | val serverTextView: TextView = view.findViewById(R.id.textView)
114 | val dragHandle: ImageView = itemView.findViewById(R.id.dragHandle)
115 | var id = 0
116 |
117 | init {
118 | view.setOnClickListener {
119 | onItemClick?.invoke(id)
120 | }
121 | }
122 | }
123 |
124 | }
125 |
--------------------------------------------------------------------------------
/.idea/codeStyles/Project.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
45 |
46 |
47 |
48 |
49 |
50 |
51 | xmlns:android
52 |
53 | ^$
54 |
55 |
56 |
57 |
58 |
59 |
60 |
61 |
62 | xmlns:.*
63 |
64 | ^$
65 |
66 |
67 | BY_NAME
68 |
69 |
70 |
71 |
72 |
73 |
74 | .*:id
75 |
76 | http://schemas.android.com/apk/res/android
77 |
78 |
79 |
80 |
81 |
82 |
83 |
84 |
85 | .*:name
86 |
87 | http://schemas.android.com/apk/res/android
88 |
89 |
90 |
91 |
92 |
93 |
94 |
95 |
96 | name
97 |
98 | ^$
99 |
100 |
101 |
102 |
103 |
104 |
105 |
106 |
107 | style
108 |
109 | ^$
110 |
111 |
112 |
113 |
114 |
115 |
116 |
117 |
118 | .*
119 |
120 | ^$
121 |
122 |
123 | BY_NAME
124 |
125 |
126 |
127 |
128 |
129 |
130 | .*
131 |
132 | http://schemas.android.com/apk/res/android
133 |
134 |
135 | ANDROID_ATTRIBUTE_ORDER
136 |
137 |
138 |
139 |
140 |
141 |
142 | .*
143 |
144 | .*
145 |
146 |
147 | BY_NAME
148 |
149 |
150 |
151 |
152 |
153 |
154 |
155 |
156 |
157 |
--------------------------------------------------------------------------------
/app/src/main/java/ru/karasevm/privatednstoggle/ui/DNSServerDialogFragment.kt:
--------------------------------------------------------------------------------
1 | package ru.karasevm.privatednstoggle.ui
2 |
3 | import android.app.Dialog
4 | import android.content.Intent
5 | import android.os.Bundle
6 | import android.widget.Toast
7 | import androidx.fragment.app.DialogFragment
8 | import androidx.fragment.app.viewModels
9 | import androidx.lifecycle.lifecycleScope
10 | import androidx.recyclerview.widget.LinearLayoutManager
11 | import com.google.android.material.dialog.MaterialAlertDialogBuilder
12 | import kotlinx.coroutines.launch
13 | import ru.karasevm.privatednstoggle.PrivateDNSApp
14 | import ru.karasevm.privatednstoggle.R
15 | import ru.karasevm.privatednstoggle.data.DnsServerViewModel
16 | import ru.karasevm.privatednstoggle.data.DnsServerViewModelFactory
17 | import ru.karasevm.privatednstoggle.databinding.SheetDnsSelectorBinding
18 | import ru.karasevm.privatednstoggle.model.DnsServer
19 | import ru.karasevm.privatednstoggle.util.PrivateDNSUtils
20 | import ru.karasevm.privatednstoggle.util.PrivateDNSUtils.checkForPermission
21 |
22 | class DNSServerDialogFragment : DialogFragment() {
23 |
24 | private var _binding: SheetDnsSelectorBinding? = null
25 | private val binding get() = _binding!!
26 |
27 | private lateinit var linearLayoutManager: LinearLayoutManager
28 | private lateinit var adapter: ServerListRecyclerAdapter
29 | private var servers: MutableList = mutableListOf()
30 | private val dnsServerViewModel: DnsServerViewModel by viewModels { DnsServerViewModelFactory((requireActivity().application as PrivateDNSApp).repository) }
31 | private val contentResolver by lazy { requireActivity().contentResolver }
32 |
33 | override fun onCreateDialog(
34 | savedInstanceState: Bundle?
35 | ): Dialog {
36 | return activity?.let {
37 | val startIntent = Intent(context, MainActivity::class.java)
38 |
39 | val builder = MaterialAlertDialogBuilder(it)
40 | val inflater = requireActivity().layoutInflater
41 | _binding = SheetDnsSelectorBinding.inflate(inflater)
42 | linearLayoutManager = LinearLayoutManager(context)
43 | binding.recyclerView.layoutManager = linearLayoutManager
44 |
45 | adapter = ServerListRecyclerAdapter(false)
46 | binding.recyclerView.adapter = adapter
47 | lifecycleScope.launch {
48 | dnsServerViewModel.getAll().collect { s ->
49 | servers = s.toMutableList()
50 | if (servers.isEmpty()) {
51 | servers.add(DnsServer(0, "dns.google"))
52 | }
53 | servers.add(0, DnsServer(-1, resources.getString(R.string.dns_auto)))
54 | servers.add(0, DnsServer(-2, resources.getString(R.string.dns_off)))
55 | adapter.submitList(servers)
56 | }
57 | }
58 | builder.setTitle(R.string.select_server)
59 | .setView(binding.root)
60 | .setPositiveButton(
61 | R.string.done
62 | ) { _, _ ->
63 | dialog?.dismiss()
64 | }
65 | .setNeutralButton(R.string.open_app) { _, _ -> context?.startActivity(startIntent) }
66 | builder.create()
67 | } ?: throw IllegalStateException("Activity cannot be null")
68 | }
69 |
70 | override fun onStart() {
71 | super.onStart()
72 | if (!checkForPermission(requireContext())) {
73 | Toast.makeText(
74 | context, R.string.permission_missing, Toast.LENGTH_SHORT
75 | ).show()
76 | dialog!!.dismiss()
77 | }
78 | adapter.onItemClick = { id ->
79 | when (id) {
80 | OFF_ID -> {
81 | PrivateDNSUtils.setPrivateMode(
82 | contentResolver,
83 | PrivateDNSUtils.DNS_MODE_OFF
84 | )
85 | PrivateDNSUtils.setPrivateProvider(
86 | contentResolver,
87 | null)
88 | Toast.makeText(context, R.string.set_to_off_toast, Toast.LENGTH_SHORT).show()
89 | }
90 |
91 | AUTO_ID -> {
92 | PrivateDNSUtils.setPrivateMode(
93 | contentResolver,
94 | PrivateDNSUtils.DNS_MODE_AUTO
95 | )
96 | PrivateDNSUtils.setPrivateProvider(
97 | contentResolver,
98 | null)
99 | Toast.makeText(context, R.string.set_to_auto_toast, Toast.LENGTH_SHORT).show()
100 | }
101 |
102 | else -> {
103 | lifecycleScope.launch {
104 | val server = servers.find { server -> server.id == id }
105 | PrivateDNSUtils.setPrivateMode(
106 | contentResolver,
107 | PrivateDNSUtils.DNS_MODE_PRIVATE
108 | )
109 | PrivateDNSUtils.setPrivateProvider(
110 | contentResolver,
111 | server?.server
112 | )
113 | Toast.makeText(
114 | context,
115 | getString(
116 | R.string.set_to_provider_toast,
117 | server?.label?.ifEmpty { server.server }
118 | ),
119 | Toast.LENGTH_SHORT
120 | ).show()
121 | }
122 | }
123 | }
124 | dialog?.dismiss()
125 | requireContext().sendBroadcast(Intent("refresh_tile").setPackage(requireContext().packageName))
126 | }
127 |
128 | }
129 |
130 | override fun onDestroy() {
131 | super.onDestroy()
132 | activity?.finish()
133 | }
134 |
135 | companion object {
136 | const val TAG = "DNSServerDialogFragment"
137 | private const val AUTO_ID = -1
138 | private const val OFF_ID = -2
139 | }
140 | }
--------------------------------------------------------------------------------
/app/src/main/java/ru/karasevm/privatednstoggle/ui/AddServerDialogFragment.kt:
--------------------------------------------------------------------------------
1 | package ru.karasevm.privatednstoggle.ui
2 |
3 | import android.app.Dialog
4 | import android.content.Context
5 | import android.content.DialogInterface
6 | import android.os.Bundle
7 | import android.text.Editable
8 | import android.text.TextUtils
9 | import android.text.TextWatcher
10 | import androidx.appcompat.app.AlertDialog
11 | import androidx.fragment.app.DialogFragment
12 | import com.google.android.material.dialog.MaterialAlertDialogBuilder
13 | import com.google.common.net.InternetDomainName
14 | import ru.karasevm.privatednstoggle.R
15 | import ru.karasevm.privatednstoggle.databinding.DialogAddBinding
16 | import ru.karasevm.privatednstoggle.model.DnsServer
17 |
18 |
19 | class AddServerDialogFragment(
20 | private val dnsServer: DnsServer?
21 | ) : DialogFragment() {
22 | // Use this instance of the interface to deliver action events
23 | private lateinit var listener: NoticeDialogListener
24 |
25 | private var _binding: DialogAddBinding? = null
26 |
27 | // This property is only valid between onCreateView and
28 | // onDestroyView.
29 | private val binding get() = _binding!!
30 |
31 | /* The activity that creates an instance of this dialog fragment must
32 | * implement this interface in order to receive event callbacks.
33 | * Each method passes the DialogFragment in case the host needs to query it. */
34 | interface NoticeDialogListener {
35 | fun onAddDialogPositiveClick(label: String?, server: String)
36 | fun onUpdateDialogPositiveClick(id: Int, server: String, label: String?, enabled: Boolean)
37 | fun onDeleteItemClicked(id: Int)
38 | }
39 |
40 | // Override the Fragment.onAttach() method to instantiate the NoticeDialogListener
41 | override fun onAttach(context: Context) {
42 | super.onAttach(context)
43 | // Verify that the host activity implements the callback interface
44 | try {
45 | // Instantiate the NoticeDialogListener so we can send events to the host
46 | listener = context as NoticeDialogListener
47 | } catch (e: ClassCastException) {
48 | // The activity doesn't implement the interface, throw exception
49 | throw ClassCastException(
50 | (context.toString() +
51 | " must implement NoticeDialogListener")
52 | )
53 | }
54 | }
55 |
56 | override fun onCreateDialog(
57 | savedInstanceState: Bundle?
58 | ): Dialog {
59 | return activity?.let {
60 | val builder = MaterialAlertDialogBuilder(it)
61 | // Get the layout inflater
62 | val inflater = requireActivity().layoutInflater
63 | _binding = DialogAddBinding.inflate(inflater)
64 |
65 | val view = binding.root
66 | // Inflate and set the layout for the dialog
67 | // Pass null as the parent view because its going in the dialog layout
68 | if (dnsServer != null) {
69 | binding.editTextServerHint.setText(dnsServer.label)
70 | binding.editTextServerAddr.setText(dnsServer.server)
71 | binding.serverEnabledSwitch.visibility = android.view.View.VISIBLE
72 | binding.serverEnabledSwitch.isChecked = dnsServer.enabled
73 | builder.setTitle(R.string.edit_server).setView(view)
74 | .setPositiveButton(
75 | R.string.menu_save
76 | ) { _, _ ->
77 | listener.onUpdateDialogPositiveClick(
78 | dnsServer.id,
79 | binding.editTextServerAddr.text.toString().trim(),
80 | binding.editTextServerHint.text.toString().trim(),
81 | binding.serverEnabledSwitch.isChecked
82 | )
83 | }
84 | .setNegativeButton(
85 | R.string.cancel
86 | ) { _, _ ->
87 | dialog?.cancel()
88 | }
89 | .setNeutralButton(
90 | R.string.delete
91 | ) { _, _ ->
92 | listener.onDeleteItemClicked(dnsServer.id)
93 | }
94 | } else {
95 | builder.setTitle(R.string.add_server)
96 | .setView(view)
97 | // Add action buttons
98 | .setPositiveButton(
99 | R.string.menu_add
100 | ) { _, _ ->
101 | listener.onAddDialogPositiveClick(
102 | binding.editTextServerHint.text.toString().trim(),
103 | binding.editTextServerAddr.text.toString().trim()
104 | )
105 | }
106 | .setNegativeButton(
107 | R.string.cancel
108 | ) { _, _ ->
109 | dialog?.cancel()
110 | }
111 | }
112 | builder.create()
113 | } ?: throw IllegalStateException("Activity cannot be null")
114 | }
115 |
116 | override fun onStart() {
117 | super.onStart()
118 | val button = ((dialog) as AlertDialog).getButton(DialogInterface.BUTTON_POSITIVE)
119 | binding.editTextServerAddr.addTextChangedListener(object : TextWatcher {
120 | override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {}
121 |
122 | override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {}
123 |
124 | override fun afterTextChanged(s: Editable?) {
125 | val server = binding.editTextServerAddr.text.toString().trim()
126 | if (TextUtils.isEmpty(server) || !isValidServer(server)) {
127 | button.isEnabled = false
128 | } else {
129 | binding.editTextServerAddr.error = null
130 | button.isEnabled = true
131 | }
132 | }
133 | })
134 | }
135 |
136 | private fun isValidServer(str: String): Boolean {
137 | return InternetDomainName.isValid(str)
138 | }
139 |
140 | }
--------------------------------------------------------------------------------
/gradlew:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env sh
2 |
3 | #
4 | # Copyright 2015 the original author or authors.
5 | #
6 | # Licensed under the Apache License, Version 2.0 (the "License");
7 | # you may not use this file except in compliance with the License.
8 | # You may obtain a copy of the License at
9 | #
10 | # https://www.apache.org/licenses/LICENSE-2.0
11 | #
12 | # Unless required by applicable law or agreed to in writing, software
13 | # distributed under the License is distributed on an "AS IS" BASIS,
14 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15 | # See the License for the specific language governing permissions and
16 | # limitations under the License.
17 | #
18 |
19 | ##############################################################################
20 | ##
21 | ## Gradle start up script for UN*X
22 | ##
23 | ##############################################################################
24 |
25 | # Attempt to set APP_HOME
26 | # Resolve links: $0 may be a link
27 | PRG="$0"
28 | # Need this for relative symlinks.
29 | while [ -h "$PRG" ] ; do
30 | ls=`ls -ld "$PRG"`
31 | link=`expr "$ls" : '.*-> \(.*\)$'`
32 | if expr "$link" : '/.*' > /dev/null; then
33 | PRG="$link"
34 | else
35 | PRG=`dirname "$PRG"`"/$link"
36 | fi
37 | done
38 | SAVED="`pwd`"
39 | cd "`dirname \"$PRG\"`/" >/dev/null
40 | APP_HOME="`pwd -P`"
41 | cd "$SAVED" >/dev/null
42 |
43 | APP_NAME="Gradle"
44 | APP_BASE_NAME=`basename "$0"`
45 |
46 | # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
47 | DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
48 |
49 | # Use the maximum available, or set MAX_FD != -1 to use that value.
50 | MAX_FD="maximum"
51 |
52 | warn () {
53 | echo "$*"
54 | }
55 |
56 | die () {
57 | echo
58 | echo "$*"
59 | echo
60 | exit 1
61 | }
62 |
63 | # OS specific support (must be 'true' or 'false').
64 | cygwin=false
65 | msys=false
66 | darwin=false
67 | nonstop=false
68 | case "`uname`" in
69 | CYGWIN* )
70 | cygwin=true
71 | ;;
72 | Darwin* )
73 | darwin=true
74 | ;;
75 | MINGW* )
76 | msys=true
77 | ;;
78 | NONSTOP* )
79 | nonstop=true
80 | ;;
81 | esac
82 |
83 | CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
84 |
85 |
86 | # Determine the Java command to use to start the JVM.
87 | if [ -n "$JAVA_HOME" ] ; then
88 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
89 | # IBM's JDK on AIX uses strange locations for the executables
90 | JAVACMD="$JAVA_HOME/jre/sh/java"
91 | else
92 | JAVACMD="$JAVA_HOME/bin/java"
93 | fi
94 | if [ ! -x "$JAVACMD" ] ; then
95 | die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
96 |
97 | Please set the JAVA_HOME variable in your environment to match the
98 | location of your Java installation."
99 | fi
100 | else
101 | JAVACMD="java"
102 | which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
103 |
104 | Please set the JAVA_HOME variable in your environment to match the
105 | location of your Java installation."
106 | fi
107 |
108 | # Increase the maximum file descriptors if we can.
109 | if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then
110 | MAX_FD_LIMIT=`ulimit -H -n`
111 | if [ $? -eq 0 ] ; then
112 | if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then
113 | MAX_FD="$MAX_FD_LIMIT"
114 | fi
115 | ulimit -n $MAX_FD
116 | if [ $? -ne 0 ] ; then
117 | warn "Could not set maximum file descriptor limit: $MAX_FD"
118 | fi
119 | else
120 | warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT"
121 | fi
122 | fi
123 |
124 | # For Darwin, add options to specify how the application appears in the dock
125 | if $darwin; then
126 | GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\""
127 | fi
128 |
129 | # For Cygwin or MSYS, switch paths to Windows format before running java
130 | if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then
131 | APP_HOME=`cygpath --path --mixed "$APP_HOME"`
132 | CLASSPATH=`cygpath --path --mixed "$CLASSPATH"`
133 |
134 | JAVACMD=`cygpath --unix "$JAVACMD"`
135 |
136 | # We build the pattern for arguments to be converted via cygpath
137 | ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null`
138 | SEP=""
139 | for dir in $ROOTDIRSRAW ; do
140 | ROOTDIRS="$ROOTDIRS$SEP$dir"
141 | SEP="|"
142 | done
143 | OURCYGPATTERN="(^($ROOTDIRS))"
144 | # Add a user-defined pattern to the cygpath arguments
145 | if [ "$GRADLE_CYGPATTERN" != "" ] ; then
146 | OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)"
147 | fi
148 | # Now convert the arguments - kludge to limit ourselves to /bin/sh
149 | i=0
150 | for arg in "$@" ; do
151 | CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -`
152 | CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option
153 |
154 | if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition
155 | eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"`
156 | else
157 | eval `echo args$i`="\"$arg\""
158 | fi
159 | i=`expr $i + 1`
160 | done
161 | case $i in
162 | 0) set -- ;;
163 | 1) set -- "$args0" ;;
164 | 2) set -- "$args0" "$args1" ;;
165 | 3) set -- "$args0" "$args1" "$args2" ;;
166 | 4) set -- "$args0" "$args1" "$args2" "$args3" ;;
167 | 5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;;
168 | 6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;;
169 | 7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;;
170 | 8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;;
171 | 9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;;
172 | esac
173 | fi
174 |
175 | # Escape application args
176 | save () {
177 | for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done
178 | echo " "
179 | }
180 | APP_ARGS=`save "$@"`
181 |
182 | # Collect all arguments for the java command, following the shell quoting and substitution rules
183 | eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS"
184 |
185 | exec "$JAVACMD" "$@"
186 |
--------------------------------------------------------------------------------
/app/src/main/java/ru/karasevm/privatednstoggle/util/PrivateDNSUtils.kt:
--------------------------------------------------------------------------------
1 | package ru.karasevm.privatednstoggle.util
2 |
3 | import android.Manifest
4 | import android.content.ContentResolver
5 | import android.content.Context
6 | import android.content.SharedPreferences
7 | import android.content.pm.PackageManager
8 | import android.provider.Settings
9 | import android.util.Log
10 | import androidx.core.content.ContextCompat.checkSelfPermission
11 | import kotlinx.coroutines.CoroutineScope
12 | import kotlinx.coroutines.launch
13 | import ru.karasevm.privatednstoggle.data.DnsServerRepository
14 | import ru.karasevm.privatednstoggle.model.DnsServer
15 | import ru.karasevm.privatednstoggle.util.PreferenceHelper.autoMode
16 |
17 | object PrivateDNSUtils {
18 | const val DNS_MODE_OFF = "off"
19 | const val DNS_MODE_AUTO = "opportunistic"
20 | const val DNS_MODE_PRIVATE = "hostname"
21 |
22 | // What options to use when cycling
23 | const val AUTO_MODE_OPTION_OFF = 0
24 | const val AUTO_MODE_OPTION_AUTO = 1
25 | const val AUTO_MODE_OPTION_OFF_AUTO = 2
26 | const val AUTO_MODE_OPTION_PRIVATE = 3
27 |
28 | private const val PRIVATE_DNS_MODE = "private_dns_mode"
29 | private const val PRIVATE_DNS_PROVIDER = "private_dns_specifier"
30 |
31 |
32 | // Gets the system dns mode
33 | fun getPrivateMode(contentResolver: ContentResolver): String? {
34 | return Settings.Global.getString(contentResolver, PRIVATE_DNS_MODE)
35 | }
36 |
37 | // Gets the system dns provider
38 | fun getPrivateProvider(contentResolver: ContentResolver): String? {
39 | return Settings.Global.getString(contentResolver, PRIVATE_DNS_PROVIDER)
40 | }
41 |
42 | // Sets the system dns mode
43 | fun setPrivateMode(contentResolver: ContentResolver, value: String) {
44 | Settings.Global.putString(contentResolver, PRIVATE_DNS_MODE, value)
45 | }
46 |
47 | // Sets the system dns provider
48 | fun setPrivateProvider(contentResolver: ContentResolver, value: String?) {
49 | Settings.Global.putString(contentResolver, PRIVATE_DNS_PROVIDER, value)
50 | }
51 |
52 | fun checkForPermission(context: Context): Boolean {
53 | return (checkSelfPermission(
54 | context, Manifest.permission.WRITE_SECURE_SETTINGS
55 | ) == PackageManager.PERMISSION_GRANTED)
56 | }
57 |
58 | /**
59 | * Gets next dns address from the database,
60 | * if current address is last or unknown returns null
61 | *
62 | * @param currentAddress currently set address
63 | * @return next address
64 | */
65 | suspend fun getNextAddress(
66 | repository: DnsServerRepository, currentAddress: String?
67 | ): DnsServer? {
68 | return if (currentAddress.isNullOrEmpty()) {
69 | repository.getFirstEnabled()
70 | } else {
71 | repository.getNextByServer(currentAddress)
72 | }
73 | }
74 |
75 | /**
76 | * Gets next dns mode while preserving the current dns provider
77 | *
78 | * @param sharedPreferences shared preferences
79 | * @param scope coroutine scope
80 | * @param repository dns server repository
81 | * @param contentResolver content resolver
82 | * @param onNext callback to invoke with the next dns mode and current dns provider
83 | */
84 | fun getNextMode(
85 | sharedPreferences: SharedPreferences,
86 | scope: CoroutineScope,
87 | repository: DnsServerRepository,
88 | contentResolver: ContentResolver,
89 | onNext: ((String, String?) -> Unit)
90 | ) {
91 | Log.d("PrivateDNSUtils", "getNextMode: called")
92 | val systemDnsMode = getPrivateMode(contentResolver)
93 | val systemDnsProvider = getPrivateProvider(contentResolver)
94 |
95 |
96 | // System dns mode is off or auto
97 | if (systemDnsMode.equals(DNS_MODE_OFF, ignoreCase = true) || systemDnsMode.equals(DNS_MODE_AUTO, ignoreCase = true)) {
98 | if (systemDnsProvider == null) { // no provider set, use regular logic
99 | getNextProvider(
100 | sharedPreferences,
101 | scope,
102 | repository,
103 | contentResolver,
104 | onNext = { mode, provider -> onNext(mode, provider) })
105 | } else {
106 | if (systemDnsMode.equals(DNS_MODE_OFF, ignoreCase = true) && (sharedPreferences.autoMode == AUTO_MODE_OPTION_OFF_AUTO || sharedPreferences.autoMode == AUTO_MODE_OPTION_AUTO)) {
107 | // If system dns mode is off and auto is enabled switch to auto
108 | onNext(DNS_MODE_AUTO, systemDnsProvider)
109 | } else {
110 | // If system dns mode is off or auto, the next mode is private, and the system dns provider is set
111 | onNext(DNS_MODE_PRIVATE, systemDnsProvider)
112 | }
113 | }
114 | } else {
115 | // System dns mode is private
116 | when (sharedPreferences.autoMode) {
117 | AUTO_MODE_OPTION_PRIVATE -> onNext(DNS_MODE_OFF, systemDnsProvider)
118 | AUTO_MODE_OPTION_AUTO -> onNext(DNS_MODE_AUTO, systemDnsProvider)
119 | AUTO_MODE_OPTION_OFF_AUTO -> onNext(DNS_MODE_OFF, systemDnsProvider)
120 | AUTO_MODE_OPTION_OFF -> onNext(DNS_MODE_OFF, systemDnsProvider)
121 | }
122 | }
123 | }
124 |
125 | /**
126 | * Gets next dns provider from the database, taking into account the current
127 | * auto mode and private dns mode.
128 | *
129 | * @param sharedPreferences shared preferences
130 | * @param scope coroutine scope
131 | * @param repository dns server repository
132 | * @param contentResolver content resolver
133 | * @param onNext callback to invoke with the next dns mode and current dns provider
134 | */
135 | fun getNextProvider(
136 | sharedPreferences: SharedPreferences,
137 | scope: CoroutineScope,
138 | repository: DnsServerRepository,
139 | contentResolver: ContentResolver,
140 | onNext: ((String, String?) -> Unit)
141 | ) {
142 | Log.d("PrivateDNSUtils", "getNextProvider: called")
143 | val systemDnsMode = getPrivateMode(contentResolver)
144 | val systemDnsProvider = getPrivateProvider(contentResolver)
145 |
146 | // System dns mode is off
147 | if (systemDnsMode.equals(DNS_MODE_OFF, ignoreCase = true)) {
148 | if (sharedPreferences.autoMode == AUTO_MODE_OPTION_AUTO || sharedPreferences.autoMode == AUTO_MODE_OPTION_OFF_AUTO) {
149 | // if auto available set to auto preserving provider
150 | onNext.invoke(DNS_MODE_AUTO, systemDnsProvider)
151 | } else {
152 | // otherwise set to private with first provider
153 | scope.launch {
154 | onNext.invoke(
155 | DNS_MODE_PRIVATE,
156 | getNextAddress(repository, null)?.server
157 | )
158 | }
159 | }
160 | // system dns mode is auto or unknown
161 | } else if (systemDnsMode == null || systemDnsMode.equals(DNS_MODE_AUTO, ignoreCase = true)) {
162 | // set to private with first provider
163 | scope.launch {
164 | onNext.invoke(
165 | DNS_MODE_PRIVATE,
166 | getNextAddress(repository, null)?.server
167 | )
168 | }
169 | // system dns mode is private
170 | } else if (systemDnsMode.equals(DNS_MODE_PRIVATE, ignoreCase = true)) {
171 | scope.launch {
172 | val nextAddress = getNextAddress(repository, systemDnsProvider)
173 | if (nextAddress == null) {
174 | // if there are no more providers, set to first
175 | if (sharedPreferences.autoMode == AUTO_MODE_OPTION_PRIVATE) {
176 | onNext.invoke(DNS_MODE_PRIVATE, getNextAddress(repository, null)!!.server)
177 | } else {
178 | // otherwise set to auto or off
179 | if (sharedPreferences.autoMode == AUTO_MODE_OPTION_AUTO) {
180 | onNext.invoke(DNS_MODE_AUTO, systemDnsProvider)
181 | } else {
182 | onNext.invoke(DNS_MODE_OFF, systemDnsProvider)
183 | }
184 | }
185 | } else {
186 | // otherwise set to private with next provider
187 | onNext.invoke(DNS_MODE_PRIVATE, nextAddress.server)
188 | }
189 | }
190 | }
191 | }
192 |
193 | }
--------------------------------------------------------------------------------
/app/src/main/java/ru/karasevm/privatednstoggle/service/DnsTileService.kt:
--------------------------------------------------------------------------------
1 | package ru.karasevm.privatednstoggle.service
2 |
3 | import android.content.BroadcastReceiver
4 | import android.content.Context
5 | import android.content.Intent
6 | import android.content.IntentFilter
7 | import android.graphics.drawable.Icon
8 | import android.provider.Settings
9 | import android.service.quicksettings.Tile
10 | import android.service.quicksettings.TileService
11 | import android.util.Log
12 | import androidx.core.content.ContextCompat
13 | import kotlinx.coroutines.CoroutineScope
14 | import kotlinx.coroutines.Dispatchers
15 | import kotlinx.coroutines.SupervisorJob
16 | import kotlinx.coroutines.cancelChildren
17 | import kotlinx.coroutines.launch
18 | import ru.karasevm.privatednstoggle.PrivateDNSApp
19 | import ru.karasevm.privatednstoggle.R
20 | import ru.karasevm.privatednstoggle.data.DnsServerRepository
21 | import ru.karasevm.privatednstoggle.util.PreferenceHelper
22 | import ru.karasevm.privatednstoggle.util.PreferenceHelper.requireUnlock
23 | import ru.karasevm.privatednstoggle.util.PrivateDNSUtils
24 | import ru.karasevm.privatednstoggle.util.PrivateDNSUtils.DNS_MODE_AUTO
25 | import ru.karasevm.privatednstoggle.util.PrivateDNSUtils.DNS_MODE_OFF
26 | import ru.karasevm.privatednstoggle.util.PrivateDNSUtils.DNS_MODE_PRIVATE
27 | import ru.karasevm.privatednstoggle.util.PrivateDNSUtils.checkForPermission
28 |
29 | class DnsTileService : TileService() {
30 |
31 | private val repository: DnsServerRepository by lazy { (application as PrivateDNSApp).repository }
32 | private val job = SupervisorJob()
33 | private val scope = CoroutineScope(Dispatchers.IO + job)
34 | private val sharedPreferences by lazy { PreferenceHelper.defaultPreference(this) }
35 | private var isBroadcastReceiverRegistered = false
36 |
37 | override fun onTileAdded() {
38 | super.onTileAdded()
39 | checkForPermission(this)
40 | // Update state
41 | qsTile.state = Tile.STATE_INACTIVE
42 |
43 | // Update looks
44 | qsTile.updateTile()
45 | }
46 |
47 | /**
48 | * Set's the state of the tile and system settings to the next state
49 | */
50 | private fun cycleState() {
51 | PrivateDNSUtils.getNextProvider(sharedPreferences, scope, repository, contentResolver, onNext = { mode, provider ->
52 | changeDNSServer(mode, provider)
53 | })
54 | }
55 |
56 | /**
57 | * Sets the state of the tile to the provided values
58 | * @param mode dns mode
59 | * @param dnsProvider dns provider
60 | */
61 | private fun changeDNSServer(mode: String, dnsProvider: String?) {
62 | when (mode) {
63 | DNS_MODE_OFF -> {
64 | changeTileState(
65 | qsTile,
66 | Tile.STATE_INACTIVE,
67 | getString(R.string.dns_off),
68 | R.drawable.ic_off_black_24dp,
69 | DNS_MODE_OFF,
70 | null
71 | )
72 | }
73 |
74 | DNS_MODE_AUTO -> {
75 | changeTileState(
76 | qsTile,
77 | Tile.STATE_INACTIVE,
78 | getString(R.string.dns_auto),
79 | R.drawable.ic_auto_black_24dp,
80 | DNS_MODE_AUTO,
81 | dnsProvider
82 | )
83 | }
84 |
85 | DNS_MODE_PRIVATE -> {
86 | scope.launch {
87 | if (dnsProvider == null) {
88 | return@launch
89 | }
90 | val dnsServer = repository.getFirstByServer( dnsProvider)
91 | if (dnsServer != null) {
92 | changeTileState(
93 | qsTile,
94 | Tile.STATE_ACTIVE,
95 | dnsServer.label.ifEmpty { dnsServer.server },
96 | R.drawable.ic_private_black_24dp,
97 | DNS_MODE_PRIVATE,
98 | dnsServer.server
99 | )
100 | }
101 | }
102 | }
103 | }
104 | }
105 |
106 | override fun onClick() {
107 | super.onClick()
108 | if (!checkForPermission(this)) {
109 | return
110 | }
111 |
112 | // Require unlock to change mode according to user preference
113 | val requireUnlock = sharedPreferences.requireUnlock
114 | if (isLocked && requireUnlock) {
115 | unlockAndRun(this::cycleState)
116 | } else {
117 | cycleState()
118 | }
119 |
120 |
121 | }
122 |
123 | /**
124 | * Refreshes the state of the tile
125 | */
126 | private fun refreshTile() {
127 | val isPermissionGranted = checkForPermission(this)
128 | val dnsMode = Settings.Global.getString(contentResolver, "private_dns_mode")
129 | when (dnsMode?.lowercase()) {
130 | DNS_MODE_OFF -> {
131 | setTile(
132 | qsTile,
133 | if (!isPermissionGranted) Tile.STATE_UNAVAILABLE else Tile.STATE_INACTIVE,
134 | getString(R.string.dns_off),
135 | R.drawable.ic_off_black_24dp
136 | )
137 | }
138 |
139 | DNS_MODE_AUTO -> {
140 | setTile(
141 | qsTile,
142 | if (!isPermissionGranted) Tile.STATE_UNAVAILABLE else Tile.STATE_INACTIVE,
143 | getString(R.string.dns_auto),
144 | R.drawable.ic_auto_black_24dp
145 | )
146 | }
147 |
148 | DNS_MODE_PRIVATE -> {
149 | scope.launch {
150 | val activeAddress =
151 | Settings.Global.getString(contentResolver, "private_dns_specifier")
152 | val dnsServer = repository.getFirstByServer(activeAddress)
153 | setTile(
154 | qsTile,
155 | if (!isPermissionGranted) Tile.STATE_UNAVAILABLE else Tile.STATE_ACTIVE,
156 | // display server address if either there is no label or the server is not known
157 | dnsServer?.label?.ifBlank { activeAddress } ?: activeAddress,
158 | R.drawable.ic_private_black_24dp
159 | )
160 | }
161 | }
162 |
163 | else -> {
164 | setTile(
165 | qsTile,
166 | if (!isPermissionGranted) Tile.STATE_UNAVAILABLE else Tile.STATE_INACTIVE,
167 | getString(R.string.dns_unknown),
168 | R.drawable.ic_unknown_black_24dp
169 | )
170 | }
171 | }
172 | }
173 |
174 | private val broadcastReceiver = object : BroadcastReceiver() {
175 | override fun onReceive(context: Context, intent: Intent) {
176 | refreshTile()
177 | }
178 | }
179 |
180 | override fun onStartListening() {
181 | super.onStartListening()
182 |
183 | // Prevent some crashes
184 | if (qsTile == null) {
185 | Log.w(TAG, "onStartListening: qsTile is null")
186 | return
187 | }
188 |
189 |
190 | // Receive broadcasts to update the tile when server is changed from the dialog
191 | ContextCompat.registerReceiver(
192 | this,
193 | broadcastReceiver,
194 | IntentFilter("refresh_tile"),
195 | ContextCompat.RECEIVER_NOT_EXPORTED
196 | )
197 | isBroadcastReceiverRegistered = true
198 | refreshTile()
199 |
200 | }
201 |
202 | override fun onStopListening() {
203 | super.onStopListening()
204 | if (isBroadcastReceiverRegistered) {
205 | unregisterReceiver(broadcastReceiver)
206 | isBroadcastReceiverRegistered = false
207 | }
208 |
209 | }
210 |
211 | override fun onDestroy() {
212 | super.onDestroy()
213 | job.cancelChildren()
214 | }
215 |
216 | /**
217 | * Updates tile to specified parameters
218 | *
219 | * @param tile tile to update
220 | * @param state tile state
221 | * @param label tile label
222 | * @param icon tile icon
223 | */
224 | private fun setTile(tile: Tile, state: Int, label: String?, icon: Int) {
225 | tile.state = state
226 | tile.label = label
227 | tile.icon = Icon.createWithResource(this, icon)
228 | tile.updateTile()
229 | }
230 |
231 | /**
232 | * Updates tile and system settings to specified parameters
233 | *
234 | * @param tile tile to update
235 | * @param state tile state
236 | * @param label tile label
237 | * @param icon tile icon
238 | * @param dnsMode system dns mode
239 | * @param dnsProvider system dns provider
240 | */
241 | private fun changeTileState(
242 | tile: Tile,
243 | state: Int,
244 | label: String?,
245 | icon: Int,
246 | dnsMode: String,
247 | dnsProvider: String?
248 | ) {
249 | tile.label = label
250 | tile.state = state
251 | tile.icon = Icon.createWithResource(this, icon)
252 | PrivateDNSUtils.setPrivateMode(contentResolver, dnsMode)
253 | PrivateDNSUtils.setPrivateProvider(contentResolver, dnsProvider)
254 | tile.updateTile()
255 | }
256 |
257 | companion object {
258 | private const val TAG = "DnsTileService"
259 | }
260 | }
--------------------------------------------------------------------------------
/app/src/main/java/ru/karasevm/privatednstoggle/ui/MainActivity.kt:
--------------------------------------------------------------------------------
1 | package ru.karasevm.privatednstoggle.ui
2 |
3 | import android.Manifest
4 | import android.content.ClipData
5 | import android.content.ClipDescription.MIMETYPE_TEXT_PLAIN
6 | import android.content.ClipboardManager
7 | import android.content.Intent
8 | import android.content.SharedPreferences
9 | import android.content.pm.PackageManager
10 | import android.graphics.Color
11 | import android.os.Build
12 | import android.os.Bundle
13 | import android.util.Log
14 | import android.view.Menu
15 | import android.view.View
16 | import android.widget.Toast
17 | import androidx.activity.result.contract.ActivityResultContracts
18 | import androidx.activity.viewModels
19 | import androidx.appcompat.app.AppCompatActivity
20 | import androidx.core.app.ShareCompat
21 | import androidx.core.net.toUri
22 | import androidx.lifecycle.viewModelScope
23 | import androidx.recyclerview.widget.ItemTouchHelper
24 | import androidx.recyclerview.widget.ItemTouchHelper.DOWN
25 | import androidx.recyclerview.widget.ItemTouchHelper.UP
26 | import androidx.recyclerview.widget.LinearLayoutManager
27 | import androidx.recyclerview.widget.RecyclerView
28 | import kotlinx.coroutines.launch
29 | import kotlinx.serialization.json.Json
30 | import rikka.shizuku.Shizuku
31 | import rikka.shizuku.ShizukuProvider
32 | import ru.karasevm.privatednstoggle.PrivateDNSApp
33 | import ru.karasevm.privatednstoggle.R
34 | import ru.karasevm.privatednstoggle.data.DnsServerViewModel
35 | import ru.karasevm.privatednstoggle.data.DnsServerViewModelFactory
36 | import ru.karasevm.privatednstoggle.databinding.ActivityMainBinding
37 | import ru.karasevm.privatednstoggle.model.DnsServer
38 | import ru.karasevm.privatednstoggle.util.BackupUtils
39 | import ru.karasevm.privatednstoggle.util.PreferenceHelper
40 | import ru.karasevm.privatednstoggle.util.PreferenceHelper.dns_servers
41 | import ru.karasevm.privatednstoggle.util.ShizukuUtil.grantPermissionWithShizuku
42 |
43 |
44 | class MainActivity : AppCompatActivity(), AddServerDialogFragment.NoticeDialogListener,
45 | DeleteServerDialogFragment.NoticeDialogListener, Shizuku.OnRequestPermissionResultListener {
46 |
47 | private lateinit var linearLayoutManager: LinearLayoutManager
48 | private lateinit var binding: ActivityMainBinding
49 | private lateinit var sharedPrefs: SharedPreferences
50 | private lateinit var adapter: ServerListRecyclerAdapter
51 | private lateinit var clipboard: ClipboardManager
52 | private val dnsServerViewModel: DnsServerViewModel by viewModels { DnsServerViewModelFactory((application as PrivateDNSApp).repository) }
53 |
54 | private val itemTouchHelper by lazy {
55 | val simpleItemTouchCallback =
56 | object : ItemTouchHelper.SimpleCallback(UP or DOWN, 0) {
57 | var dragFrom = -1
58 | var dragTo = -1
59 |
60 | override fun onMove(
61 | recyclerView: RecyclerView,
62 | viewHolder: RecyclerView.ViewHolder,
63 | target: RecyclerView.ViewHolder
64 | ): Boolean {
65 | if (dragFrom == viewHolder.bindingAdapterPosition && dragTo == target.bindingAdapterPosition) {
66 | return true
67 | }
68 | // store the drag position
69 | if (dragFrom == -1) dragFrom = viewHolder.bindingAdapterPosition
70 | dragTo = target.bindingAdapterPosition
71 | adapter.onItemMove(
72 | viewHolder.bindingAdapterPosition,
73 | target.bindingAdapterPosition
74 | )
75 | return true
76 | }
77 |
78 | override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) {}
79 |
80 | override fun onSelectedChanged(
81 | viewHolder: RecyclerView.ViewHolder?, actionState: Int
82 | ) {
83 | super.onSelectedChanged(viewHolder, actionState)
84 | if (actionState == ItemTouchHelper.ACTION_STATE_DRAG) {
85 | viewHolder?.itemView?.apply {
86 | // Example: Elevate the view
87 | elevation = 8f
88 | alpha = 0.5f
89 | setBackgroundColor(Color.GRAY)
90 | }
91 | }
92 | }
93 |
94 | override fun clearView(
95 | recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder
96 | ) {
97 | super.clearView(recyclerView, viewHolder)
98 | viewHolder.itemView.apply {
99 | // Reset the appearance
100 | elevation = 0f
101 | alpha = 1.0f
102 | setBackgroundColor(Color.TRANSPARENT)
103 | }
104 | // commit the change to the db
105 | dnsServerViewModel.move(
106 | dragFrom,
107 | dragTo,
108 | (viewHolder as ServerListRecyclerAdapter.DnsServerViewHolder).id
109 | )
110 | dragTo = -1
111 | dragFrom = -1
112 | }
113 | }
114 | ItemTouchHelper(simpleItemTouchCallback)
115 | }
116 |
117 | private fun importSettings(json: String) {
118 | runCatching {
119 | val data: BackupUtils.Backup = Json.decodeFromString(json)
120 | BackupUtils.import(data, dnsServerViewModel, sharedPrefs)
121 | }.onSuccess {
122 | Toast.makeText(
123 | this, getString(R.string.import_success), Toast.LENGTH_SHORT
124 | ).show()
125 | }.onFailure { exception ->
126 | runCatching {
127 | Log.e("IMPORT", "Malformed json, falling back to legacy", exception)
128 | val data = Json.decodeFromString(json)
129 | BackupUtils.importLegacy(data, dnsServerViewModel, sharedPrefs)
130 | }.onSuccess {
131 | Toast.makeText(
132 | this, getString(R.string.import_success), Toast.LENGTH_SHORT
133 | ).show()
134 | }.onFailure { exception ->
135 | Log.e("IMPORT", "Import failed", exception)
136 | Toast.makeText(
137 | this, getString(R.string.import_failure), Toast.LENGTH_SHORT
138 | ).show()
139 | }
140 | }
141 | }
142 |
143 | /**
144 | * Migrate the SharedPreferences server list to Room
145 | */
146 | private fun migrateServerList() {
147 | dnsServerViewModel.viewModelScope.launch {
148 | if (sharedPrefs.dns_servers.isNotEmpty() && sharedPrefs.dns_servers[0] != "") {
149 | Log.i(
150 | "migrate",
151 | "existing sharedPrefs list: ${sharedPrefs.dns_servers} ${sharedPrefs.dns_servers.size}"
152 | )
153 | sharedPrefs.dns_servers.forEach { server ->
154 | val parts = server.split(" : ").toMutableList()
155 | if (parts.size != 2) parts.add(0, "")
156 | Log.i("migrate", "migrating: $server -> $parts")
157 | dnsServerViewModel.insert(DnsServer(0, parts[1], parts[0]))
158 | }
159 | sharedPrefs.dns_servers = emptyList().toMutableList()
160 | }
161 | }
162 | }
163 |
164 | override fun onCreate(savedInstanceState: Bundle?) {
165 | super.onCreate(savedInstanceState)
166 |
167 | Shizuku.addRequestPermissionResultListener(this::onRequestPermissionResult)
168 |
169 | binding = ActivityMainBinding.inflate(layoutInflater)
170 | val view = binding.root
171 | setContentView(view)
172 |
173 | linearLayoutManager = LinearLayoutManager(this)
174 | binding.recyclerView.layoutManager = linearLayoutManager
175 |
176 | sharedPrefs = PreferenceHelper.defaultPreference(this)
177 | clipboard = getSystemService(CLIPBOARD_SERVICE) as ClipboardManager
178 |
179 | migrateServerList()
180 |
181 | adapter = ServerListRecyclerAdapter(true)
182 | binding.recyclerView.adapter = adapter
183 |
184 | dnsServerViewModel.allServers.observe(this) { servers ->
185 | adapter.submitList(servers)
186 | if (servers.isEmpty()) {
187 | binding.emptyView.visibility = View.VISIBLE
188 | binding.emptyViewHint.visibility = View.VISIBLE
189 | } else {
190 | binding.emptyView.visibility = View.GONE
191 | binding.emptyViewHint.visibility = View.GONE
192 | }
193 | }
194 | adapter.onItemClick = { id ->
195 | dnsServerViewModel.viewModelScope.launch {
196 | val server = dnsServerViewModel.getById(id)
197 | if (server != null) {
198 | val newFragment =
199 | AddServerDialogFragment(server)
200 | newFragment.show(supportFragmentManager, "edit_server")
201 | }
202 | }
203 | }
204 | adapter.onDragStart = { viewHolder ->
205 | itemTouchHelper.startDrag(viewHolder)
206 | }
207 | binding.floatingActionButton.setOnClickListener {
208 | val newFragment = AddServerDialogFragment(null)
209 | newFragment.show(supportFragmentManager, "add_server")
210 | }
211 | binding.recyclerView.adapter = adapter
212 | itemTouchHelper.attachToRecyclerView(binding.recyclerView)
213 |
214 | binding.topAppBar.setOnMenuItemClickListener { item ->
215 | when (item.itemId) {
216 | R.id.privacy_policy -> {
217 | val browserIntent = Intent(
218 | Intent.ACTION_VIEW,
219 | "https://karasevm.github.io/PrivateDNSAndroid/privacy_policy".toUri()
220 | )
221 | startActivity(browserIntent)
222 | true
223 | }
224 |
225 | R.id.export_settings_clipboard -> {
226 | dnsServerViewModel.viewModelScope.launch {
227 | val data = BackupUtils.export(dnsServerViewModel, sharedPrefs)
228 | val jsonData = Json.encodeToString(data)
229 | clipboard.setPrimaryClip(ClipData.newPlainText("", jsonData))
230 | // Only show a toast for Android 12 and lower.
231 | if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.S_V2) Toast.makeText(
232 | applicationContext, getString(R.string.copy_success), Toast.LENGTH_SHORT
233 | ).show()
234 | }
235 | true
236 | }
237 |
238 | R.id.export_settings_share -> {
239 | val activityContext = this
240 | dnsServerViewModel.viewModelScope.launch {
241 | val data = BackupUtils.export(dnsServerViewModel, sharedPrefs)
242 | val jsonData = Json.encodeToString(data)
243 | ShareCompat.IntentBuilder(activityContext).setText(jsonData)
244 | .setType("text/plain")
245 | .startChooser()
246 | }
247 | true
248 | }
249 |
250 | R.id.export_settings_file -> {
251 |
252 | dnsServerViewModel.viewModelScope.launch {
253 | val intent = Intent(Intent.ACTION_CREATE_DOCUMENT).apply {
254 | addCategory(Intent.CATEGORY_OPENABLE)
255 | type = "text/plain"
256 | putExtra(Intent.EXTRA_TITLE, "private-dns-export")
257 | }
258 | saveResultLauncher.launch(intent)
259 | }
260 | true
261 | }
262 |
263 | R.id.import_settings_file -> {
264 | val intent = Intent(Intent.ACTION_OPEN_DOCUMENT).apply {
265 | addCategory(Intent.CATEGORY_OPENABLE)
266 | type = "text/plain"
267 | }
268 | importResultLauncher.launch(intent)
269 | true
270 | }
271 |
272 | R.id.import_settings_clipboard -> {
273 | val clipData = clipboard.primaryClip?.getItemAt(0)
274 | val textData = clipData?.text
275 |
276 | if (textData != null) {
277 | importSettings(textData.toString())
278 | }
279 | true
280 | }
281 |
282 | R.id.options -> {
283 | val newFragment = OptionsDialogFragment()
284 | newFragment.show(supportFragmentManager, "options")
285 | true
286 | }
287 |
288 | R.id.open_source_licenses -> {
289 | // start license activity
290 | val intent = Intent(this, AboutLibsActivity::class.java)
291 | startActivity(intent)
292 | true
293 | }
294 | else -> true
295 | }
296 | }
297 | }
298 |
299 | private var saveResultLauncher =
300 | registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
301 | if (result.resultCode == RESULT_OK) {
302 | val data: Intent? = result.data
303 | data?.data?.also { uri ->
304 | val jsonData =
305 | Json.encodeToString(BackupUtils.export(dnsServerViewModel, sharedPrefs))
306 | val contentResolver = applicationContext.contentResolver
307 | runCatching {
308 | contentResolver.openOutputStream(uri)?.use { outputStream ->
309 | outputStream.write(jsonData.toByteArray())
310 | }
311 | }.onFailure { exception ->
312 | Log.e("EXPORT", "Export failed", exception)
313 | Toast.makeText(
314 | this, getString(R.string.export_failure), Toast.LENGTH_SHORT
315 | ).show()
316 | }.onSuccess {
317 | Toast.makeText(
318 | this, getString(R.string.export_success), Toast.LENGTH_SHORT
319 | ).show()
320 | }
321 | }
322 | }
323 | }
324 |
325 | private var importResultLauncher =
326 | registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
327 | if (result.resultCode == RESULT_OK) {
328 | val data: Intent? = result.data
329 | data?.data?.also { uri ->
330 | val contentResolver = applicationContext.contentResolver
331 | contentResolver.openInputStream(uri)?.use { inputStream ->
332 | val jsonData = inputStream.bufferedReader().use { it.readText() }
333 | importSettings(jsonData)
334 | }
335 | }
336 | }
337 | }
338 |
339 | override fun onCreateOptionsMenu(menu: Menu?): Boolean {
340 | menuInflater.inflate(R.menu.menu_main, menu)
341 | return true
342 | }
343 |
344 | override fun onResume() {
345 | super.onResume()
346 | // Check if WRITE_SECURE_SETTINGS is granted
347 | if (checkSelfPermission(Manifest.permission.WRITE_SECURE_SETTINGS) != PackageManager.PERMISSION_GRANTED) {
348 | // Check if Shizuku is available
349 | if (Shizuku.pingBinder()) {
350 | // check if permission is granted already
351 | val isGranted = if (Shizuku.isPreV11() || Shizuku.getVersion() < 11) {
352 | checkSelfPermission(ShizukuProvider.PERMISSION) == PackageManager.PERMISSION_GRANTED
353 | } else {
354 | Shizuku.checkSelfPermission() == PackageManager.PERMISSION_GRANTED
355 | }
356 | // request permission if not granted
357 | if (!isGranted && !Shizuku.shouldShowRequestPermissionRationale()) {
358 | if (Shizuku.isPreV11() || Shizuku.getVersion() < 11) {
359 | requestPermissions(arrayOf(ShizukuProvider.PERMISSION), 1)
360 | } else {
361 | Shizuku.requestPermission(1)
362 | }
363 | } else {
364 | grantPermission()
365 | }
366 | } else {
367 | if (checkSelfPermission(Manifest.permission.WRITE_SECURE_SETTINGS) != PackageManager.PERMISSION_GRANTED) {
368 | val browserIntent = Intent(
369 | Intent.ACTION_VIEW,
370 | "https://karasevm.github.io/PrivateDNSAndroid/".toUri()
371 | )
372 | Toast.makeText(
373 | this, R.string.shizuku_failure_toast, Toast.LENGTH_SHORT
374 | ).show()
375 | startActivity(browserIntent)
376 | finish()
377 | }
378 | }
379 | }
380 | }
381 |
382 | override fun onWindowFocusChanged(hasFocus: Boolean) {
383 | super.onWindowFocusChanged(hasFocus)
384 | if (!hasFocus) {
385 | // Gets the ID of the "paste" menu item.
386 | val pasteItem = binding.topAppBar.menu.findItem(R.id.import_settings_clipboard)
387 |
388 | // If the clipboard doesn't contain data, disable the paste menu item.
389 | // If it does contain data, decide whether you can handle the data.
390 | pasteItem.isEnabled = when {
391 | !clipboard.hasPrimaryClip() -> false
392 | !(clipboard.primaryClipDescription?.hasMimeType(MIMETYPE_TEXT_PLAIN))!! -> false
393 | else -> true
394 |
395 | }
396 | }
397 | }
398 |
399 | override fun onDestroy() {
400 | super.onDestroy()
401 | Shizuku.removeRequestPermissionResultListener(this::onRequestPermissionResult)
402 | }
403 |
404 | /**
405 | * Show the dialog for deleting the server
406 | * @param id The server id
407 | */
408 | override fun onDeleteItemClicked(id: Int) {
409 | val newFragment = DeleteServerDialogFragment(id)
410 | newFragment.show(supportFragmentManager, "delete_server")
411 | }
412 |
413 | /**
414 | * Callback for adding the server
415 | * @param label The label
416 | * @param server The server
417 | */
418 | override fun onAddDialogPositiveClick(label: String?, server: String) {
419 | if (server.isEmpty()) {
420 | Toast.makeText(this, R.string.server_length_error, Toast.LENGTH_SHORT).show()
421 | return
422 | }
423 |
424 | if (label.isNullOrEmpty()) {
425 | dnsServerViewModel.insert(DnsServer(0, server))
426 | } else {
427 | dnsServerViewModel.insert(DnsServer(0, server, label))
428 | }
429 | }
430 |
431 | /**
432 | * Callback for deleting the server
433 | * @param id The server id
434 | */
435 | override fun onDeleteDialogPositiveClick(id: Int) {
436 | dnsServerViewModel.delete(id)
437 | }
438 |
439 | /**
440 | * Callback for updating the server
441 | * @param label New label
442 | * @param server New server address
443 | * @param id The server id
444 | */
445 | override fun onUpdateDialogPositiveClick(
446 | id: Int,
447 | server: String,
448 | label: String?,
449 | enabled: Boolean
450 | ) {
451 | if (server.isEmpty()) {
452 | Toast.makeText(this, R.string.server_length_error, Toast.LENGTH_SHORT).show()
453 | return
454 | }
455 | dnsServerViewModel.update(id, server, label, null, enabled)
456 | }
457 |
458 | private fun grantPermission() {
459 | if (grantPermissionWithShizuku(this)) {
460 | Toast.makeText(
461 | this, R.string.shizuku_success_toast, Toast.LENGTH_SHORT
462 | ).show()
463 | } else {
464 | Toast.makeText(
465 | this, R.string.shizuku_failure_toast, Toast.LENGTH_SHORT
466 | ).show()
467 | val browserIntent = Intent(
468 | Intent.ACTION_VIEW, "https://karasevm.github.io/PrivateDNSAndroid/".toUri()
469 | )
470 | startActivity(browserIntent)
471 | finish()
472 | }
473 | }
474 |
475 | override fun onRequestPermissionResult(requestCode: Int, grantResult: Int) {
476 | val isGranted = grantResult == PackageManager.PERMISSION_GRANTED
477 |
478 | if (!isGranted && checkSelfPermission(Manifest.permission.WRITE_SECURE_SETTINGS) != PackageManager.PERMISSION_GRANTED) {
479 | val browserIntent = Intent(
480 | Intent.ACTION_VIEW, "https://karasevm.github.io/PrivateDNSAndroid/".toUri()
481 | )
482 | startActivity(browserIntent)
483 | finish()
484 | }
485 | }
486 | }
--------------------------------------------------------------------------------