├── app ├── .gitignore ├── src │ ├── main │ │ ├── res │ │ │ ├── resources.properties │ │ │ ├── values-es │ │ │ │ └── strings.xml │ │ │ ├── values-az │ │ │ │ └── strings.xml │ │ │ ├── drawable │ │ │ │ ├── ic_baseline_add_24.xml │ │ │ │ ├── ic_drag_handle_24.xml │ │ │ │ ├── ic_shortcut_mode_24dp.xml │ │ │ │ ├── ic_auto_black_24dp.xml │ │ │ │ ├── ic_baseline_settings_24.xml │ │ │ │ ├── ic_unknown_black_24dp.xml │ │ │ │ ├── ic_off_black_24dp.xml │ │ │ │ ├── ic_shortcut_next_24dp.xml │ │ │ │ ├── ic_private_black_24dp.xml │ │ │ │ └── ic_launcher_foreground.xml │ │ │ ├── mipmap │ │ │ │ ├── ic_launcher.xml │ │ │ │ └── ic_launcher_debug.xml │ │ │ ├── values-night │ │ │ │ └── themes.xml │ │ │ ├── values │ │ │ │ ├── colors.xml │ │ │ │ ├── themes.xml │ │ │ │ └── strings.xml │ │ │ ├── layout │ │ │ │ ├── sheet_dns_selector.xml │ │ │ │ ├── recyclerview_row.xml │ │ │ │ ├── dialog_options.xml │ │ │ │ ├── dialog_add.xml │ │ │ │ └── activity_main.xml │ │ │ ├── xml │ │ │ │ └── shortcuts.xml │ │ │ ├── menu │ │ │ │ └── menu_main.xml │ │ │ ├── values-zh-rCN │ │ │ │ └── strings.xml │ │ │ ├── values-zh-rTW │ │ │ │ └── strings.xml │ │ │ ├── values-mn │ │ │ │ └── strings.xml │ │ │ ├── values-it │ │ │ │ └── strings.xml │ │ │ ├── values-vi │ │ │ │ └── strings.xml │ │ │ ├── values-sr │ │ │ │ └── strings.xml │ │ │ ├── values-tr │ │ │ │ └── strings.xml │ │ │ ├── values-uk │ │ │ │ └── strings.xml │ │ │ ├── values-hu │ │ │ │ └── strings.xml │ │ │ ├── values-pl │ │ │ │ └── strings.xml │ │ │ ├── values-pt-rBR │ │ │ │ └── strings.xml │ │ │ ├── values-fr │ │ │ │ └── strings.xml │ │ │ ├── values-ru │ │ │ │ └── strings.xml │ │ │ └── values-ta │ │ │ │ └── strings.xml │ │ ├── ic_launcher-playstore.png │ │ ├── java │ │ │ └── ru │ │ │ │ └── karasevm │ │ │ │ └── privatednstoggle │ │ │ │ ├── ui │ │ │ │ ├── SettingsDialogActivity.kt │ │ │ │ ├── DeleteServerDialogFragment.kt │ │ │ │ ├── AboutLibsActivity.kt │ │ │ │ ├── OptionsDialogFragment.kt │ │ │ │ ├── ServerListRecyclerAdapter.kt │ │ │ │ ├── DNSServerDialogFragment.kt │ │ │ │ ├── AddServerDialogFragment.kt │ │ │ │ └── MainActivity.kt │ │ │ │ ├── ShortcutHandlerActivity.kt │ │ │ │ ├── model │ │ │ │ └── DnsServer.kt │ │ │ │ ├── PrivateDNSApp.kt │ │ │ │ ├── data │ │ │ │ ├── database │ │ │ │ │ └── DnsServerRoomDatabase.kt │ │ │ │ ├── DnsServerRepository.kt │ │ │ │ ├── DnsServerViewModel.kt │ │ │ │ └── DnsServerDao.kt │ │ │ │ ├── util │ │ │ │ ├── SharedPrefUtils.kt │ │ │ │ ├── BackupUtils.kt │ │ │ │ ├── ShizukuUtil.kt │ │ │ │ └── PrivateDNSUtils.kt │ │ │ │ └── service │ │ │ │ ├── ShortcutService.kt │ │ │ │ └── DnsTileService.kt │ │ └── AndroidManifest.xml │ ├── test │ │ └── java │ │ │ └── ru │ │ │ └── karasevm │ │ │ └── privatednstoggle │ │ │ └── ExampleUnitTest.kt │ ├── androidTest │ │ └── java │ │ │ └── ru │ │ │ └── karasevm │ │ │ └── privatednstoggle │ │ │ └── ExampleInstrumentedTest.kt │ └── debug │ │ └── res │ │ └── xml │ │ └── shortcuts.xml ├── proguard-rules.pro └── build.gradle.kts ├── .idea ├── .name ├── .gitignore ├── codeStyles │ ├── codeStyleConfig.xml │ └── Project.xml ├── vcs.xml ├── compiler.xml └── kotlinc.xml ├── fastlane └── metadata │ └── android │ └── en-US │ ├── title.txt │ ├── short_description.txt │ ├── changelogs │ ├── 13.txt │ ├── 15.txt │ ├── 11.txt │ ├── default.txt │ ├── 14.txt │ ├── 12.txt │ ├── 16.txt │ ├── 17.txt │ ├── 19.txt │ └── 18.txt │ ├── images │ ├── icon.png │ └── phoneScreenshots │ │ ├── 01.png │ │ ├── 02.png │ │ ├── 03.png │ │ ├── 04.png │ │ └── 05.png │ └── full_description.txt ├── readme.jpg ├── .gitattributes ├── gradle └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── .github └── ISSUE_TEMPLATE │ ├── 03-other.yml │ ├── 02-feature-request.yml │ └── 01-bug-report.yml ├── settings.gradle.kts ├── LICENSE ├── .gitignore ├── gradle.properties ├── gradlew.bat ├── README.md └── gradlew /app/.gitignore: -------------------------------------------------------------------------------- 1 | /build -------------------------------------------------------------------------------- /.idea/.name: -------------------------------------------------------------------------------- 1 | Private DNS Quick Toggle -------------------------------------------------------------------------------- /app/src/main/res/resources.properties: -------------------------------------------------------------------------------- 1 | unqualifiedResLocale=en-US -------------------------------------------------------------------------------- /fastlane/metadata/android/en-US/title.txt: -------------------------------------------------------------------------------- 1 | Private DNS Quick Toggle -------------------------------------------------------------------------------- /.idea/.gitignore: -------------------------------------------------------------------------------- 1 | # Default ignored files 2 | /shelf/ 3 | /workspace.xml 4 | -------------------------------------------------------------------------------- /readme.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/karasevm/PrivateDNSAndroid/HEAD/readme.jpg -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # Auto detect text files and perform LF normalization 2 | * text=auto 3 | -------------------------------------------------------------------------------- /app/src/main/res/values-es/strings.xml: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /fastlane/metadata/android/en-US/short_description.txt: -------------------------------------------------------------------------------- 1 | Quick settings tile to switch active private DNS server -------------------------------------------------------------------------------- /app/src/main/res/values-az/strings.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /fastlane/metadata/android/en-US/changelogs/13.txt: -------------------------------------------------------------------------------- 1 | - Settings export/import 2 | - Fix label not appearing in some cases -------------------------------------------------------------------------------- /fastlane/metadata/android/en-US/changelogs/15.txt: -------------------------------------------------------------------------------- 1 | - Fix crashes on Android 11 and earlier 2 | - Fix list entry layout 3 | -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/karasevm/PrivateDNSAndroid/HEAD/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /app/src/main/ic_launcher-playstore.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/karasevm/PrivateDNSAndroid/HEAD/app/src/main/ic_launcher-playstore.png -------------------------------------------------------------------------------- /fastlane/metadata/android/en-US/changelogs/11.txt: -------------------------------------------------------------------------------- 1 | - Add option to require unlocking the device to use the tile 2 | - Fix invisible nav buttons on some devices -------------------------------------------------------------------------------- /fastlane/metadata/android/en-US/images/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/karasevm/PrivateDNSAndroid/HEAD/fastlane/metadata/android/en-US/images/icon.png -------------------------------------------------------------------------------- /fastlane/metadata/android/en-US/changelogs/default.txt: -------------------------------------------------------------------------------- 1 | Changelog for latest release is available on GitHub: 2 | https://github.com/karasevm/PrivateDNSAndroid/releases/latest -------------------------------------------------------------------------------- /fastlane/metadata/android/en-US/changelogs/14.txt: -------------------------------------------------------------------------------- 1 | - Add an option to edit servers 2 | - Add placeholder for empty server list 3 | - Fix layout for longer server addresses 4 | -------------------------------------------------------------------------------- /fastlane/metadata/android/en-US/images/phoneScreenshots/01.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/karasevm/PrivateDNSAndroid/HEAD/fastlane/metadata/android/en-US/images/phoneScreenshots/01.png -------------------------------------------------------------------------------- /fastlane/metadata/android/en-US/images/phoneScreenshots/02.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/karasevm/PrivateDNSAndroid/HEAD/fastlane/metadata/android/en-US/images/phoneScreenshots/02.png -------------------------------------------------------------------------------- /fastlane/metadata/android/en-US/images/phoneScreenshots/03.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/karasevm/PrivateDNSAndroid/HEAD/fastlane/metadata/android/en-US/images/phoneScreenshots/03.png -------------------------------------------------------------------------------- /fastlane/metadata/android/en-US/images/phoneScreenshots/04.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/karasevm/PrivateDNSAndroid/HEAD/fastlane/metadata/android/en-US/images/phoneScreenshots/04.png -------------------------------------------------------------------------------- /fastlane/metadata/android/en-US/images/phoneScreenshots/05.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/karasevm/PrivateDNSAndroid/HEAD/fastlane/metadata/android/en-US/images/phoneScreenshots/05.png -------------------------------------------------------------------------------- /.idea/codeStyles/codeStyleConfig.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | -------------------------------------------------------------------------------- /.idea/vcs.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /.idea/compiler.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /.idea/kotlinc.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | -------------------------------------------------------------------------------- /fastlane/metadata/android/en-US/changelogs/12.txt: -------------------------------------------------------------------------------- 1 | - Support for selection of only Private DNS in Option Dialog by @InfiniteCoder06 2 | - Support Labels by @InfiniteCoder06 3 | - Feature: Reordring by @InfiniteCoder06 4 | - Possible tile update fix -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/03-other.yml: -------------------------------------------------------------------------------- 1 | name: Other 2 | description: If other options don't fit your question. 3 | body: 4 | - type: textarea 5 | id: other 6 | attributes: 7 | label: Ask a question 8 | validations: 9 | required: true 10 | -------------------------------------------------------------------------------- /settings.gradle.kts: -------------------------------------------------------------------------------- 1 | dependencyResolutionManagement { 2 | repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) 3 | repositories { 4 | google() 5 | mavenCentral() 6 | } 7 | } 8 | rootProject.name = "Private DNS Quick Toggle" 9 | include("app") 10 | -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | #Mon Aug 16 15:36:35 MSK 2021 2 | distributionBase=GRADLE_USER_HOME 3 | distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-bin.zip 4 | distributionPath=wrapper/dists 5 | zipStorePath=wrapper/dists 6 | zipStoreBase=GRADLE_USER_HOME 7 | -------------------------------------------------------------------------------- /fastlane/metadata/android/en-US/changelogs/16.txt: -------------------------------------------------------------------------------- 1 | - Replaced server storage backend with Room, allowing for easier further expansion 2 | - Add option to disable saved servers 3 | - Improved backup handling 4 | - Fixed desync bug while dragging servers 5 | - Reorganized source file structure 6 | - Updated Kotlin version 7 | - Updated Java version 8 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_baseline_add_24.xml: -------------------------------------------------------------------------------- 1 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /app/src/main/res/mipmap/ic_launcher.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /app/src/main/res/mipmap/ic_launcher_debug.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_drag_handle_24.xml: -------------------------------------------------------------------------------- 1 | 7 | 10 | 11 | -------------------------------------------------------------------------------- /app/src/test/java/ru/karasevm/privatednstoggle/ExampleUnitTest.kt: -------------------------------------------------------------------------------- 1 | package ru.karasevm.privatednstoggle 2 | 3 | import org.junit.Test 4 | 5 | import org.junit.Assert.* 6 | 7 | /** 8 | * Example local unit test, which will execute on the development machine (host). 9 | * 10 | * See [testing documentation](http://d.android.com/tools/testing). 11 | */ 12 | class ExampleUnitTest { 13 | @Test 14 | fun addition_isCorrect() { 15 | assertEquals(4, 2 + 2) 16 | } 17 | } -------------------------------------------------------------------------------- /app/src/main/java/ru/karasevm/privatednstoggle/ui/SettingsDialogActivity.kt: -------------------------------------------------------------------------------- 1 | package ru.karasevm.privatednstoggle.ui 2 | 3 | import android.os.Bundle 4 | import androidx.appcompat.app.AppCompatActivity 5 | 6 | class SettingsDialogActivity : AppCompatActivity() { 7 | override fun onCreate(savedInstanceState: Bundle?) { 8 | super.onCreate(savedInstanceState) 9 | val newFragment = DNSServerDialogFragment() 10 | newFragment.show(supportFragmentManager, DNSServerDialogFragment.TAG) 11 | } 12 | } -------------------------------------------------------------------------------- /fastlane/metadata/android/en-US/changelogs/17.txt: -------------------------------------------------------------------------------- 1 | - Replaced server storage backend with Room, allowing for easier further expansion 2 | - Add option to disable saved servers 3 | - Improved backup handling 4 | - Fixed desync bug while dragging servers 5 | - Reorganized source file structure 6 | - Updated Kotlin version 7 | - Updated Java version 8 | - Replaced gson with kotlinx.serialization 9 | - Add Chinese Simplified translation (thanks @WeiguangTWK) 10 | - Add Russian translation 11 | - Fixed issue with provider not resetting when disabled through the dialog 12 | -------------------------------------------------------------------------------- /fastlane/metadata/android/en-US/full_description.txt: -------------------------------------------------------------------------------- 1 |

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 | 4 | 10 | 13 | 14 | 17 | 20 | 21 | 22 | 25 | 26 | 29 | 32 | 35 | 36 | 37 | 40 | 43 | -------------------------------------------------------------------------------- /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 | [![GitHub Downloads (all assets, all releases)](https://img.shields.io/github/downloads/karasevm/PrivateDNSAndroid/total)](https://github.com/karasevm/PrivateDNSAndroid/releases/latest) 2 | [![GitHub Release](https://img.shields.io/github/v/release/karasevm/PrivateDNSAndroid)](https://github.com/karasevm/PrivateDNSAndroid/releases/latest) 3 | [![IzzyOnDroid](https://img.shields.io/endpoint?url=https://apt.izzysoft.de/fdroid/api/v1/shield/ru.karasevm.privatednstoggle&label=IzzyOnDroid)](https://apt.izzysoft.de/fdroid/index/apk/ru.karasevm.privatednstoggle) 4 | [![Translation status](https://hosted.weblate.org/widget/privatednsandroid/private-dns-quick-toggle/svg-badge.svg)](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 | ![Private DNS app screenshot](readme.jpg) 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 | Translation status 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 | 41 | 42 | 43 | 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 | 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 | } --------------------------------------------------------------------------------