diff --git a/.github/ISSUE_TEMPLATE/01-bug-report.yml b/.github/ISSUE_TEMPLATE/01-bug-report.yml new file mode 100644 index 0000000..c906e0f --- /dev/null +++ b/.github/ISSUE_TEMPLATE/01-bug-report.yml @@ -0,0 +1,73 @@ +name: Bug report +description: File a bug report. +labels: ['bug'] +assignees: ['karasevm'] +body: + - type: markdown + attributes: + value: | + Thanks for taking the time to fill out this bug report! + - type: input + id: app_version + attributes: + label: Application Version + description: What version of the app are you running? + placeholder: ex. 1.0 + validations: + required: true + - type: input + id: android_version + attributes: + label: Application Version + description: What version of Android you running? + placeholder: ex. 13 + validations: + required: true + - type: input + id: device + attributes: + label: Device + description: What device are you using? + placeholder: ex. Pixel 5 + validations: + required: true + - type: dropdown + id: install_method + attributes: + label: How do you provide the permission? + options: + - Shizuku + - ADB + - Other + validations: + required: true + - type: textarea + id: what-happened + attributes: + label: What happened? + description: Also tell us, what did you expect to happen? + placeholder: A bug happened! + validations: + required: true + - type: textarea + id: steps-to-reproduce + attributes: + label: Steps to reproduce + description: | + Please describe what you did to reproduce the bug. + - type: textarea + id: logs + attributes: + label: Relevant log output + description: Please copy and paste any relevant log output. This will be automatically formatted into code, so no need for backticks. + render: shell + - type: textarea + id: screens + attributes: + label: Screenshots + description: If applicable, add screenshots to help explain your problem. + - type: textarea + id: additional-context + attributes: + label: Additional context + description: Add any other context about the problem here. diff --git a/.github/ISSUE_TEMPLATE/02-feature-request.yml b/.github/ISSUE_TEMPLATE/02-feature-request.yml new file mode 100644 index 0000000..7acccf3 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/02-feature-request.yml @@ -0,0 +1,30 @@ +name: Feature request +description: Suggest an idea for this project. +labels: ["enhancement"] +assignees: ["karasevm"] +body: + - type: textarea + id: problem + attributes: + label: Is your feature request related to a problem? Please describe. + description: A clear and concise description of what the problem is. + placeholder: I'm always frustrated when [...] + validations: + required: true + - type: textarea + id: solution + attributes: + label: Describe the solution you'd like. + description: A clear and concise description of what you want to happen. + validations: + required: true + - type: textarea + id: alternative + attributes: + label: Describe alternatives you've considered. + description: A clear and concise description of any alternative solutions or features you've considered. + - type: textarea + id: additional-context + attributes: + label: Additional context + description: Add any other context about the problem here. \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/03-other.yml b/.github/ISSUE_TEMPLATE/03-other.yml new file mode 100644 index 0000000..6ec8a5e --- /dev/null +++ b/.github/ISSUE_TEMPLATE/03-other.yml @@ -0,0 +1,9 @@ +name: Other +description: If other options don't fit your question. +body: + - type: textarea + id: other + attributes: + label: Ask a question + validations: + required: true diff --git a/.gitignore b/.gitignore index 69eda01..8d9ff13 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,15 @@ *.ap_ *.aab +# Android Studio generated files and folders +captures/ +.externalNativeBuild/ +.cxx/ +output.json + +# Release dir +app/release/* + # Files for the ART/Dalvik VM *.dex @@ -30,23 +39,16 @@ proguard/ # Android Studio Navigation editor temp files .navigation/ -# Android Studio captures folder -captures/ - # IntelliJ *.iml -.idea/workspace.xml -.idea/tasks.xml -.idea/gradle.xml -.idea/assetWizardSettings.xml -.idea/dictionaries -.idea/libraries -.idea/caches +.idea/ +misc.xml +deploymentTargetDropDown.xml +render.experimental.xml # Keystore files -# Uncomment the following lines if you do not want to check your keystore files in. -#*.jks -#*.keystore +*.jks +*.keystore # External native build folder generated in Android Studio 2.2 and later .externalNativeBuild @@ -65,3 +67,8 @@ fastlane/Preview.html fastlane/screenshots fastlane/test_output fastlane/readme.md + +# kotlin +.kotlin/ + +*~ diff --git a/.idea/compiler.xml b/.idea/compiler.xml index b589d56..b86273d 100644 --- a/.idea/compiler.xml +++ b/.idea/compiler.xml @@ -1,6 +1,6 @@ - + \ No newline at end of file diff --git a/.idea/kotlinc.xml b/.idea/kotlinc.xml index 9a55c2d..d4b7acc 100644 --- a/.idea/kotlinc.xml +++ b/.idea/kotlinc.xml @@ -1,6 +1,6 @@ - + \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml deleted file mode 100644 index f7ea510..0000000 --- a/.idea/misc.xml +++ /dev/null @@ -1,24 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml index 94a25f7..35eb1dd 100644 --- a/.idea/vcs.xml +++ b/.idea/vcs.xml @@ -1,6 +1,6 @@ - + \ No newline at end of file diff --git a/README.md b/README.md index a3ce438..57ac435 100644 --- a/README.md +++ b/README.md @@ -1,17 +1,23 @@ +[](https://github.com/karasevm/PrivateDNSAndroid/releases/latest) +[](https://github.com/karasevm/PrivateDNSAndroid/releases/latest) +[](https://apt.izzysoft.de/fdroid/index/apk/ru.karasevm.privatednstoggle) +[](https://hosted.weblate.org/engage/privatednsandroid/) + # Private DNS Quick Toggle A quick settings tile to switch your private dns provider. Supports any number of providers. Makes it easy to turn adblocking dns servers on or off with just a single tap. - + ## Installation -Get the latest apk on the [releases page](https://github.com/karasevm/PrivateDNSAndroid/releases/latest). +Get the latest apk on the [releases page](https://github.com/karasevm/PrivateDNSAndroid/releases/latest) +or from [IzzyOnDroid repo](https://apt.izzysoft.de/fdroid/index/apk/ru.karasevm.privatednstoggle). -## Automatic (Shizuku) +### Automatic (Shizuku) 1. Install and start [Shizuku](https://shizuku.rikka.app/). 2. Start the app and allow Shizuku access when prompted. -## Manual +### Manual For the app to work properly you'll need to provide it permissions via ADB: 1. Get to your PC and download platform tools from google [here](https://developer.android.com/studio/releases/platform-tools). @@ -26,3 +32,18 @@ For the app to work properly you'll need to provide it permissions via ADB: 6. That's it, you should have the app installed. +## Contributing + +### Translation +The easiest way to contribute would be to submit a translation to your language. Thanks to Weblate gratis hosting for open-source projects you can do it without any programming knowledge on [their website](https://hosted.weblate.org/engage/privatednsandroid/). +#### Translation status + + + + +### Code +If you want to contribute code please try to adhere to the following guidelines: +- Include javadoc comments for all the public methods you add +- Keep the code neatly formatted, you can you the built-in Android Studio formatter +- Please describe what your code does and how does it do that when sending a PR +- Before sending a PR please test your change on the oldest and latest supported Android versions (9 and 14 at the time of writing) diff --git a/app/build.gradle b/app/build.gradle deleted file mode 100644 index f303392..0000000 --- a/app/build.gradle +++ /dev/null @@ -1,57 +0,0 @@ -plugins { - id 'com.android.application' - id 'kotlin-android' -} - -android { - compileSdk 34 - - defaultConfig { - applicationId "ru.karasevm.privatednstoggle" - minSdk 21 - targetSdk 34 - versionCode 8 - versionName "1.1" - - testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" - targetSdkVersion 34 - minSdkVersion 28 - } - buildFeatures { - viewBinding true - } - buildTypes { - release { - minifyEnabled true - shrinkResources true - proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' - } - } - compileOptions { - sourceCompatibility JavaVersion.VERSION_1_8 - targetCompatibility JavaVersion.VERSION_1_8 - } - kotlinOptions { - jvmTarget = '1.8' - } - namespace 'ru.karasevm.privatednstoggle' -} - -dependencies { - - implementation 'androidx.core:core-ktx:1.12.0' - implementation 'androidx.appcompat:appcompat:1.6.1' - implementation 'com.google.android.material:material:1.11.0' - implementation 'androidx.constraintlayout:constraintlayout:2.1.4' - - def shizuku_version = '13.1.5' - implementation "dev.rikka.shizuku:api:$shizuku_version" - implementation "dev.rikka.shizuku:provider:$shizuku_version" - - implementation 'org.lsposed.hiddenapibypass:hiddenapibypass:4.3' - compileOnly 'dev.rikka.hidden:stub:4.3.2' - - testImplementation 'junit:junit:4.13.2' - androidTestImplementation 'androidx.test.ext:junit:1.1.5' - androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1' -} \ No newline at end of file diff --git a/app/build.gradle.kts b/app/build.gradle.kts new file mode 100644 index 0000000..c06d657 --- /dev/null +++ b/app/build.gradle.kts @@ -0,0 +1,88 @@ +plugins { + id("com.android.application") + id("kotlin-android") + id("com.google.devtools.ksp") + id("org.jetbrains.kotlin.plugin.serialization") +} + +android { + compileSdk = 35 + androidResources { + generateLocaleConfig = true + } + defaultConfig { + applicationId = "ru.karasevm.privatednstoggle" + versionCode = 18 + versionName = "1.10.0" + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + targetSdk = 35 + minSdk = 28 + } + buildFeatures { + viewBinding = true + buildConfig = true + } + buildTypes { + release { + isMinifyEnabled = true + isShrinkResources = true + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) + } + debug { + applicationIdSuffix = ".dev" + } + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + kotlinOptions { + jvmTarget = "17" + } + dependenciesInfo { + // Disables dependency metadata when building APKs. + includeInApk = false + // Disables dependency metadata when building Android App Bundles. + includeInBundle = false + } + namespace = "ru.karasevm.privatednstoggle" +} + +dependencies { + implementation("androidx.core:core-ktx:1.15.0") + implementation("androidx.appcompat:appcompat:1.7.0") + implementation("androidx.recyclerview:recyclerview:1.4.0") + implementation("androidx.activity:activity-ktx:1.10.0") + implementation("androidx.fragment:fragment-ktx:1.8.6") + implementation("com.google.android.material:material:1.12.0") + implementation("androidx.constraintlayout:constraintlayout:2.2.0") + implementation("com.google.guava:guava:33.1.0-android") + implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3") + + val shizukuVersion = "13.1.5" + implementation("dev.rikka.shizuku:api:$shizukuVersion") + implementation("dev.rikka.shizuku:provider:$shizukuVersion") + compileOnly("dev.rikka.hidden:stub:4.3.3") + + implementation("org.lsposed.hiddenapibypass:hiddenapibypass:4.3") + + // Room components + val roomVersion = "2.6.1" + implementation("androidx.room:room-ktx:$roomVersion") + ksp("androidx.room:room-compiler:$roomVersion") + androidTestImplementation("androidx.room:room-testing:$roomVersion") + + // Lifecycle components + val lifecycleVersion = "2.8.7" + implementation("androidx.lifecycle:lifecycle-viewmodel-ktx:$lifecycleVersion") + implementation("androidx.lifecycle:lifecycle-livedata-ktx:$lifecycleVersion") + implementation("androidx.lifecycle:lifecycle-common-java8:$lifecycleVersion") + + testImplementation("junit:junit:4.13.2") + androidTestImplementation("androidx.test.ext:junit:1.2.1") + androidTestImplementation("androidx.test.espresso:espresso-core:3.6.1") +} \ No newline at end of file diff --git a/app/release/output-metadata.json b/app/release/output-metadata.json deleted file mode 100644 index 45a0364..0000000 --- a/app/release/output-metadata.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "version": 3, - "artifactType": { - "type": "APK", - "kind": "Directory" - }, - "applicationId": "ru.karasevm.privatednstoggle", - "variantName": "release", - "elements": [ - { - "type": "SINGLE", - "filters": [], - "attributes": [], - "versionCode": 1, - "versionName": "1.0", - "outputFile": "app-release.apk" - } - ], - "elementType": "File" -} \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 1932f68..21da365 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -3,40 +3,53 @@ xmlns:tools="http://schemas.android.com/tools"> + + android:theme="@style/Theme.Transparent"> + - - - + + + + + + + - \ No newline at end of file diff --git a/app/src/main/java/ru/karasevm/privatednstoggle/AddServerDialogFragment.kt b/app/src/main/java/ru/karasevm/privatednstoggle/AddServerDialogFragment.kt deleted file mode 100644 index 752d4f7..0000000 --- a/app/src/main/java/ru/karasevm/privatednstoggle/AddServerDialogFragment.kt +++ /dev/null @@ -1,75 +0,0 @@ -package ru.karasevm.privatednstoggle - -import android.app.Dialog -import android.content.Context -import android.os.Bundle -import androidx.appcompat.app.AlertDialog -import androidx.fragment.app.DialogFragment -import ru.karasevm.privatednstoggle.databinding.DialogAddBinding - - -class AddServerDialogFragment : DialogFragment() { - // Use this instance of the interface to deliver action events - private lateinit var listener: NoticeDialogListener - - private var _binding: DialogAddBinding? = null - - // This property is only valid between onCreateView and - // onDestroyView. - private val binding get() = _binding!! - - /* The activity that creates an instance of this dialog fragment must - * implement this interface in order to receive event callbacks. - * Each method passes the DialogFragment in case the host needs to query it. */ - interface NoticeDialogListener { - fun onDialogPositiveClick(dialog: DialogFragment, server: String) - } - - // Override the Fragment.onAttach() method to instantiate the NoticeDialogListener - override fun onAttach(context: Context) { - super.onAttach(context) - // Verify that the host activity implements the callback interface - try { - // Instantiate the NoticeDialogListener so we can send events to the host - listener = context as NoticeDialogListener - } catch (e: ClassCastException) { - // The activity doesn't implement the interface, throw exception - throw ClassCastException( - (context.toString() + - " must implement NoticeDialogListener") - ) - } - } - - override fun onCreateDialog( - savedInstanceState: Bundle? - ): Dialog { - return activity?.let { - val builder = AlertDialog.Builder(it) - // Get the layout inflater - val inflater = requireActivity().layoutInflater - _binding = DialogAddBinding.inflate(inflater) - - val view = binding.root - // Inflate and set the layout for the dialog - // Pass null as the parent view because its going in the dialog layout - builder.setTitle(R.string.add_server) - .setView(view) - // Add action buttons - .setPositiveButton(R.string.menu_add - ) { _, _ -> - listener.onDialogPositiveClick( - this, - binding.editTextServerAddr.text.toString() - ) - } - .setNegativeButton(R.string.cancel - ) { _, _ -> - dialog?.cancel() - } - builder.create() - } ?: throw IllegalStateException("Activity cannot be null") - } - - -} \ No newline at end of file diff --git a/app/src/main/java/ru/karasevm/privatednstoggle/DnsTileService.kt b/app/src/main/java/ru/karasevm/privatednstoggle/DnsTileService.kt deleted file mode 100644 index 05da913..0000000 --- a/app/src/main/java/ru/karasevm/privatednstoggle/DnsTileService.kt +++ /dev/null @@ -1,223 +0,0 @@ -package ru.karasevm.privatednstoggle - -import android.Manifest -import android.content.pm.PackageManager -import android.graphics.drawable.Icon -import android.provider.Settings -import android.service.quicksettings.Tile -import android.service.quicksettings.TileService -import android.widget.Toast - - -const val DNS_MODE_OFF = "off" -const val DNS_MODE_AUTO = "opportunistic" -const val DNS_MODE_PRIVATE = "hostname" - -class DnsTileService : TileService() { - - - private fun checkForPermission(): Boolean { - if (checkSelfPermission(Manifest.permission.WRITE_SECURE_SETTINGS) == PackageManager.PERMISSION_GRANTED) { - return true - } - Toast.makeText(this, R.string.permission_missing, Toast.LENGTH_SHORT).show() - return false - } - - override fun onTileAdded() { - super.onTileAdded() - checkForPermission() - // Update state - qsTile.state = Tile.STATE_INACTIVE - - // Update looks - qsTile.updateTile() - } - - override fun onClick() { - super.onClick() - if (!checkForPermission()) { - return - } - - val dnsMode = Settings.Global.getString(contentResolver, "private_dns_mode") - val dnsProvider = Settings.Global.getString(contentResolver, "private_dns_specifier") - - val sharedPrefs = this.getSharedPreferences("app_prefs", 0) - val autoEnabled = sharedPrefs.getBoolean("auto_enabled", false) - - - if (dnsMode.equals(DNS_MODE_OFF, ignoreCase = true)) { -// refreshTile(qsTile, Tile.STATE_INACTIVE, getString(R.string.dns_off), R.drawable.ic_off_black_24dp) - if (autoEnabled) { - changeTileState( - qsTile, - Tile.STATE_ACTIVE, - getString(R.string.dns_auto), - R.drawable.ic_auto_black_24dp, - DNS_MODE_AUTO, - dnsProvider - ) - } else { - changeTileState( - qsTile, - Tile.STATE_ACTIVE, - getNextAddress(dnsProvider), - R.drawable.ic_private_black_24dp, - DNS_MODE_PRIVATE, - getNextAddress(dnsProvider) - ) - - } - - } else if (dnsMode == null || dnsMode.equals(DNS_MODE_AUTO, ignoreCase = true)) { - changeTileState( - qsTile, - Tile.STATE_ACTIVE, - getNextAddress(dnsProvider), - R.drawable.ic_private_black_24dp, - DNS_MODE_PRIVATE, - getNextAddress(dnsProvider) - ) - } else if (dnsMode.equals(DNS_MODE_PRIVATE, ignoreCase = true)) { - if (getNextAddress(dnsProvider) == null) { - changeTileState( - qsTile, - Tile.STATE_INACTIVE, - getString(R.string.dns_off), - R.drawable.ic_off_black_24dp, - DNS_MODE_OFF, - getNextAddress(dnsProvider) - ) - } else { - changeTileState( - qsTile, - Tile.STATE_ACTIVE, - getNextAddress(dnsProvider), - R.drawable.ic_private_black_24dp, - DNS_MODE_PRIVATE, - getNextAddress(dnsProvider) - ) - } - } - - } - - override fun onStartListening() { - super.onStartListening() - if (!checkForPermission()) { - return - } - val dnsMode = Settings.Global.getString(contentResolver, "private_dns_mode") - - // Prevent some crashes - if (qsTile == null) { - return - } - - if (dnsMode.equals(DNS_MODE_OFF, ignoreCase = true)) { - refreshTile( - qsTile, - Tile.STATE_INACTIVE, - getString(R.string.dns_off), - R.drawable.ic_off_black_24dp - ) - } else if (dnsMode == null) { - refreshTile( - qsTile, - Tile.STATE_INACTIVE, - getString(R.string.dns_unknown), - R.drawable.ic_unknown_black_24dp - ) - } else if (dnsMode.equals(DNS_MODE_AUTO, ignoreCase = true)) { - refreshTile( - qsTile, - Tile.STATE_ACTIVE, - getString(R.string.dns_auto), - R.drawable.ic_auto_black_24dp - ) - } else if (dnsMode.equals(DNS_MODE_PRIVATE, ignoreCase = true)) { - val dnsProvider = Settings.Global.getString(contentResolver, "private_dns_specifier") - if (dnsProvider != null) { - refreshTile( - qsTile, - Tile.STATE_ACTIVE, - dnsProvider, - R.drawable.ic_private_black_24dp - ) - } else { - Toast.makeText(this, R.string.permission_missing, Toast.LENGTH_SHORT).show() - } - } - - } - - /** - * Updates tile to specified parameters - * - * @param tile tile to update - * @param state tile state - * @param label tile label - * @param icon tile icon - */ - private fun refreshTile(tile: Tile, state: Int, label: String?, icon: Int) { - tile.state = state - tile.label = label - tile.icon = Icon.createWithResource(this, icon) - tile.updateTile() - } - - /** - * Updates tile and system settings to specified parameters - * - * @param tile tile to update - * @param state tile state - * @param label tile label - * @param icon tile icon - * @param dnsMode system dns mode - * @param dnsProvider system dns provider - */ - private fun changeTileState( - tile: Tile, - state: Int, - label: String?, - icon: Int, - dnsMode: String, - dnsProvider: String? - ) { - tile.label = label - tile.state = state - tile.icon = Icon.createWithResource(this, icon) - Settings.Global.putString(contentResolver, "private_dns_mode", dnsMode) - Settings.Global.putString(contentResolver, "private_dns_specifier", dnsProvider) - tile.updateTile() - } - - /** - * Gets next dns address from preferences, - * if current address is last returns null - * - * @param currentAddress currently set address - * @return next address - */ - private fun getNextAddress(currentAddress: String?): String? { - val sharedPrefs = this.getSharedPreferences("app_prefs", 0) - val items = sharedPrefs.getString("dns_servers", "dns.google")!!.split(",").toMutableList() - - // Fallback if list is empty - if (items[0] == "") { - items.removeAt(0) - items.add("dns.google") - } - - val index = items.indexOf(currentAddress) - - if (index == -1 || currentAddress == null) { - return items[0] - } - if (index == items.size - 1) { - return null - } - return items[index + 1] - } -} \ No newline at end of file diff --git a/app/src/main/java/ru/karasevm/privatednstoggle/MainActivity.kt b/app/src/main/java/ru/karasevm/privatednstoggle/MainActivity.kt deleted file mode 100644 index 15701b0..0000000 --- a/app/src/main/java/ru/karasevm/privatednstoggle/MainActivity.kt +++ /dev/null @@ -1,223 +0,0 @@ -package ru.karasevm.privatednstoggle - -import android.Manifest -import android.annotation.SuppressLint -import android.content.Intent -import android.content.SharedPreferences -import android.content.pm.IPackageManager -import android.content.pm.PackageManager -import android.net.Uri -import android.os.Build -import android.os.Bundle -import android.permission.IPermissionManager -import android.util.Log -import android.view.Menu -import android.view.MenuItem -import android.widget.Toast -import androidx.appcompat.app.AppCompatActivity -import androidx.fragment.app.DialogFragment -import androidx.recyclerview.widget.LinearLayoutManager -import org.lsposed.hiddenapibypass.HiddenApiBypass -import rikka.shizuku.Shizuku -import rikka.shizuku.ShizukuBinderWrapper -import rikka.shizuku.ShizukuProvider -import rikka.shizuku.SystemServiceHelper -import ru.karasevm.privatednstoggle.databinding.ActivityMainBinding - - -class MainActivity : AppCompatActivity(), AddServerDialogFragment.NoticeDialogListener, DeleteServerDialogFragment.NoticeDialogListener, Shizuku.OnRequestPermissionResultListener { - - private lateinit var linearLayoutManager: LinearLayoutManager - private lateinit var binding: ActivityMainBinding - private var items = mutableListOf() - private lateinit var sharedPrefs: SharedPreferences - private lateinit var adapter: RecyclerAdapter - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - - Shizuku.addRequestPermissionResultListener(this::onRequestPermissionResult) - - binding = ActivityMainBinding.inflate(layoutInflater) - val view = binding.root - setContentView(view) - - linearLayoutManager = LinearLayoutManager(this) - binding.recyclerView.layoutManager = linearLayoutManager - - sharedPrefs = this.getSharedPreferences("app_prefs", 0) - - items = sharedPrefs.getString("dns_servers", "")!!.split(",").toMutableList() - if (items[0] == "") { - items.removeAt(0) - } - adapter = RecyclerAdapter(items) - adapter.onItemClick = { position -> - val newFragment = DeleteServerDialogFragment(position) - newFragment.show(supportFragmentManager, "delete_server") - } - binding.recyclerView.adapter = adapter - } - - override fun onCreateOptionsMenu(menu: Menu?): Boolean { - - val curVal = sharedPrefs.getBoolean("auto_enabled", false) - menuInflater.inflate(R.menu.menu_main, menu) - menu?.findItem(R.id.enable_auto)?.setChecked(curVal) - - return true - } - - override fun onResume() { - super.onResume() - // Check if WRITE_SECURE_SETTINGS is granted - if (checkSelfPermission(Manifest.permission.WRITE_SECURE_SETTINGS) != PackageManager.PERMISSION_GRANTED) { - // Check if Shizuku is available - if (Shizuku.pingBinder()) { - // check if permission is granted already - val isGranted = if (Shizuku.isPreV11() || Shizuku.getVersion() < 11) { - checkSelfPermission(ShizukuProvider.PERMISSION) == PackageManager.PERMISSION_GRANTED - } else { - Shizuku.checkSelfPermission() == PackageManager.PERMISSION_GRANTED - } - // request permission if not granted - if (!isGranted && !Shizuku.shouldShowRequestPermissionRationale()) { - if (Shizuku.isPreV11() || Shizuku.getVersion() < 11) { - requestPermissions(arrayOf(ShizukuProvider.PERMISSION), 1) - } else { - Shizuku.requestPermission(1) - } - } else { - // if shizuku permission is granted, but WRITE_SECURE_SETTINGS is not, grant it - try { - grantPermissionWithShizuku() - } catch (exception: Exception) { - Log.e("SHIZUKU", "onRequestPermissionResult: ", exception) - val browserIntent = Intent(Intent.ACTION_VIEW, Uri.parse("https://karasevm.github.io/PrivateDNSAndroid/")) - startActivity(browserIntent) - finish() - } - } - } else { - if (checkSelfPermission(Manifest.permission.WRITE_SECURE_SETTINGS) != PackageManager.PERMISSION_GRANTED) { - val browserIntent = Intent( - Intent.ACTION_VIEW, - Uri.parse("https://karasevm.github.io/PrivateDNSAndroid/") - ) - startActivity(browserIntent) - finish() - } - } - } - } - - override fun onDestroy() { - super.onDestroy() - Shizuku.removeRequestPermissionResultListener(this::onRequestPermissionResult) - } - - override fun onOptionsItemSelected(item: MenuItem) = when (item.itemId) { - R.id.add_server -> { - val newFragment = AddServerDialogFragment() - newFragment.show(supportFragmentManager, "add_server") - true - } - R.id.privacy_policy -> { - val browserIntent = Intent(Intent.ACTION_VIEW, Uri.parse("https://karasevm.github.io/PrivateDNSAndroid/privacy_policy")) - startActivity(browserIntent) - true - } - - R.id.enable_auto -> { - if (!item.isChecked){ - Toast.makeText(this, R.string.auto_mode_clarification, Toast.LENGTH_LONG).show() - } - sharedPrefs.edit().putBoolean("auto_enabled", !item.isChecked).apply() - item.setChecked(!item.isChecked) - - true - } - - else -> { - // If we got here, the user's action was not recognized. - // Invoke the superclass to handle it. - super.onOptionsItemSelected(item) - } - } - - override fun onDialogPositiveClick(dialog: DialogFragment, server: String) { - if (server.isEmpty()) { - Toast.makeText(this, R.string.server_length_error, Toast.LENGTH_SHORT).show() - return - } - items.add(server) - adapter.setData(items.toMutableList()) - binding.recyclerView.adapter?.notifyItemInserted(items.size - 1) - sharedPrefs.edit() - .putString("dns_servers", items.joinToString(separator = ",") { it }).apply() - } - - override fun onDialogPositiveClick(dialog: DialogFragment,position: Int) { - items.removeAt(position) - adapter.setData(items.toMutableList()) - adapter.notifyItemRemoved(position) - sharedPrefs.edit() - .putString("dns_servers", items.joinToString(separator = ",") { it }).apply() - - } - - /** - * Attempts to grant WRITE_SECURE_SETTINGS permission with Shizuku - */ - private fun grantPermissionWithShizuku() { - val packageName = "ru.karasevm.privatednstoggle" - if (Build.VERSION.SDK_INT >= 31) { - HiddenApiBypass.addHiddenApiExemptions( - "Landroid/permission" - ) - val binder = - ShizukuBinderWrapper(SystemServiceHelper.getSystemService("permissionmgr")) - val pm = IPermissionManager.Stub.asInterface(binder) - pm.grantRuntimePermission( - packageName, - Manifest.permission.WRITE_SECURE_SETTINGS, - 0 - ) - } else { - val binder = ShizukuBinderWrapper(SystemServiceHelper.getSystemService("package")) - val pm = IPackageManager.Stub.asInterface(binder) - pm.grantRuntimePermission( - packageName, - Manifest.permission.WRITE_SECURE_SETTINGS, - 0 - ) - } - if (checkSelfPermission(Manifest.permission.WRITE_SECURE_SETTINGS) != PackageManager.PERMISSION_GRANTED) { - val browserIntent = Intent(Intent.ACTION_VIEW, Uri.parse("https://karasevm.github.io/PrivateDNSAndroid/")) - startActivity(browserIntent) - finish() - } - } - @SuppressLint("PrivateApi") - override fun onRequestPermissionResult(requestCode: Int, grantResult: Int) { - val isGranted = grantResult == PackageManager.PERMISSION_GRANTED - - if (isGranted) { - try { - grantPermissionWithShizuku() - } catch (exception: Exception) { - Log.e("SHIZUKU", "onRequestPermissionResult: ", exception) - val browserIntent = Intent(Intent.ACTION_VIEW, Uri.parse("https://karasevm.github.io/PrivateDNSAndroid/")) - startActivity(browserIntent) - finish() - } - - } else if (checkSelfPermission(Manifest.permission.WRITE_SECURE_SETTINGS) != PackageManager.PERMISSION_GRANTED) { - val browserIntent = Intent(Intent.ACTION_VIEW, Uri.parse("https://karasevm.github.io/PrivateDNSAndroid/")) - startActivity(browserIntent) - finish() - } - - } - -} \ No newline at end of file diff --git a/app/src/main/java/ru/karasevm/privatednstoggle/PrivateDNSApp.kt b/app/src/main/java/ru/karasevm/privatednstoggle/PrivateDNSApp.kt new file mode 100644 index 0000000..c404503 --- /dev/null +++ b/app/src/main/java/ru/karasevm/privatednstoggle/PrivateDNSApp.kt @@ -0,0 +1,33 @@ +package ru.karasevm.privatednstoggle + +import android.app.Application +import android.os.StrictMode +import com.google.android.material.color.DynamicColors +import ru.karasevm.privatednstoggle.data.DnsServerRepository +import ru.karasevm.privatednstoggle.data.database.DnsServerRoomDatabase + +class PrivateDNSApp : Application() { + + private val database by lazy { DnsServerRoomDatabase.getDatabase(this) } + val repository by lazy { DnsServerRepository(database.dnsServerDao()) } + + override fun onCreate() { + super.onCreate() + DynamicColors.applyToActivitiesIfAvailable(this) + + if (BuildConfig.DEBUG){ + StrictMode.setThreadPolicy( + StrictMode.ThreadPolicy.Builder() + .detectAll() + .penaltyLog() + .build() + ) + StrictMode.setVmPolicy( + StrictMode.VmPolicy.Builder() + .detectAll() + .penaltyLog() + .build() + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/ru/karasevm/privatednstoggle/RecyclerAdapter.kt b/app/src/main/java/ru/karasevm/privatednstoggle/RecyclerAdapter.kt deleted file mode 100644 index e0949a8..0000000 --- a/app/src/main/java/ru/karasevm/privatednstoggle/RecyclerAdapter.kt +++ /dev/null @@ -1,50 +0,0 @@ -package ru.karasevm.privatednstoggle - -import android.content.Context -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import android.widget.TextView -import androidx.recyclerview.widget.RecyclerView -import java.util.* - -class RecyclerAdapter(val items: MutableList): RecyclerView.Adapter() { - - var onItemClick: ((Int) -> Unit)? = null - - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerAdapter.ViewHolder { - val view = LayoutInflater.from(parent.context).inflate(R.layout.recyclerview_row, parent, false) - val vh = ViewHolder(view) - return vh - } - - override fun onBindViewHolder(holder: RecyclerAdapter.ViewHolder, position: Int) { - val item = items[position] - - // sets the text to the textview from our itemHolder class - holder.textView.text = item - } - - override fun getItemCount(): Int { - return items.size - } - - - - inner class ViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) { - val textView: TextView = itemView.findViewById(R.id.textView) - init { - itemView.setOnClickListener { - onItemClick?.invoke(adapterPosition) - } - } - } - fun setData(newItems: MutableList) { - items.run { - clear() - addAll(newItems) - } - } - - -} \ No newline at end of file diff --git a/app/src/main/java/ru/karasevm/privatednstoggle/data/DnsServerDao.kt b/app/src/main/java/ru/karasevm/privatednstoggle/data/DnsServerDao.kt new file mode 100644 index 0000000..026de12 --- /dev/null +++ b/app/src/main/java/ru/karasevm/privatednstoggle/data/DnsServerDao.kt @@ -0,0 +1,84 @@ +package ru.karasevm.privatednstoggle.data + +import androidx.room.Dao +import androidx.room.Query +import androidx.room.Transaction +import kotlinx.coroutines.flow.Flow +import ru.karasevm.privatednstoggle.model.DnsServer + +@Dao +interface DnsServerDao { + + @Query("SELECT * FROM dns_servers ORDER BY sortOrder ASC") + fun getAll(): Flow> + + @Query("SELECT * FROM dns_servers WHERE enabled = 1 ORDER BY sortOrder ASC LIMIT 1") + suspend fun getFirstEnabled(): DnsServer + + @Query("SELECT * FROM dns_servers WHERE server = :server LIMIT 1") + suspend fun getFirstByServer(server: String): DnsServer? + + @Query("SELECT * FROM dns_servers WHERE id = :id") + suspend fun getById(id: Int): DnsServer? + + @Query("SELECT * FROM dns_servers " + + "WHERE sortOrder > (SELECT sortOrder FROM dns_servers WHERE server = :server) AND enabled = 1 " + + "ORDER BY sortOrder ASC " + + "LIMIT 1") + suspend fun getNextEnabledByServer(server: String): DnsServer? + + @Query("DELETE FROM dns_servers") + suspend fun deleteAll() + + @Query("DELETE FROM dns_servers WHERE id = :id") + suspend fun deleteById(id: Int) + + @Query("UPDATE dns_servers SET sortOrder = sortOrder + 1 " + + "WHERE sortOrder >= :startSortOrder AND sortOrder <= :endSortOrder") + suspend fun incrementSortOrder(startSortOrder: Int, endSortOrder: Int = Int.MAX_VALUE) + + @Query("UPDATE dns_servers SET sortOrder = sortOrder - 1 " + + "WHERE sortOrder >= :startSortOrder AND sortOrder <= :endSortOrder") + suspend fun decrementSortOrder(startSortOrder: Int, endSortOrder: Int = Int.MAX_VALUE) + + @Query("UPDATE dns_servers SET sortOrder = sortOrder - 1 " + + "WHERE sortOrder > (SELECT sortOrder FROM dns_servers WHERE id = :id)") + suspend fun decrementSortOrderById(id: Int) + + @Transaction + suspend fun deleteAndDecrement(id: Int) { + decrementSortOrderById(id) + deleteById(id) + } + + @Query("UPDATE dns_servers SET label = :label WHERE id = :id") + suspend fun updateLabel(id: Int, label: String) + + @Query("UPDATE dns_servers SET server = :server WHERE id = :id") + suspend fun updateServer(id: Int, server: String) + + @Query("UPDATE dns_servers " + + "SET server = COALESCE(:server, server), " + + " label = COALESCE(:label, label), " + + " sortOrder = COALESCE(:sortOrder, sortOrder), " + + " enabled = COALESCE(:enabled, enabled) " + + "WHERE id = :id") + suspend fun update(id: Int, server: String?, label: String?, sortOrder: Int?, enabled: Boolean?) + + @Transaction + suspend fun moveUp(sortOrder: Int, newSortOrder: Int, id: Int){ + incrementSortOrder(newSortOrder, sortOrder) + update(id, null, null, newSortOrder, null) + } + + @Transaction + suspend fun moveDown(sortOrder: Int, newSortOrder: Int, id: Int){ + decrementSortOrder(sortOrder, newSortOrder) + update(id, null, null, newSortOrder, null) + } + + @Query("INSERT INTO dns_servers(server, label, sortOrder, enabled) " + + "VALUES(:server, :label, COALESCE((SELECT MAX(sortOrder) + 1 FROM dns_servers), 0), :enabled)") + suspend fun insert(server: String, label: String, enabled: Boolean) + +} \ No newline at end of file diff --git a/app/src/main/java/ru/karasevm/privatednstoggle/data/DnsServerRepository.kt b/app/src/main/java/ru/karasevm/privatednstoggle/data/DnsServerRepository.kt new file mode 100644 index 0000000..4c7ddd4 --- /dev/null +++ b/app/src/main/java/ru/karasevm/privatednstoggle/data/DnsServerRepository.kt @@ -0,0 +1,60 @@ +package ru.karasevm.privatednstoggle.data + +import androidx.annotation.WorkerThread +import kotlinx.coroutines.flow.Flow +import ru.karasevm.privatednstoggle.model.DnsServer + +class DnsServerRepository(private val dnsServerDao: DnsServerDao) { + + val allServers: Flow> = dnsServerDao.getAll() + + @WorkerThread + fun getAll() = dnsServerDao.getAll() + + @WorkerThread + suspend fun getFirstEnabled() = dnsServerDao.getFirstEnabled() + + @WorkerThread + suspend fun getById(id: Int) = dnsServerDao.getById(id) + + @WorkerThread + suspend fun getFirstByServer(server: String) = dnsServerDao.getFirstByServer(server) + + @WorkerThread + suspend fun getNextByServer(server: String) = dnsServerDao.getNextEnabledByServer(server) + + @WorkerThread + suspend fun insert(dnsServer: DnsServer) { + dnsServerDao.insert(dnsServer.server, dnsServer.label, dnsServer.enabled) + } + + @WorkerThread + suspend fun update( + id: Int, + server: String?, + label: String?, + sortOrder: Int?, + enabled: Boolean? + ) { + dnsServerDao.update(id, server, label, sortOrder, enabled) + } + + @WorkerThread + suspend fun move(sortOrder: Int, newSortOrder: Int, id: Int) { + if (sortOrder == newSortOrder) { + return + } + if (newSortOrder > sortOrder) { + dnsServerDao.moveDown(sortOrder, newSortOrder, id) + } else { + dnsServerDao.moveUp(sortOrder, newSortOrder, id) + } + } + + @WorkerThread + suspend fun delete(id: Int) { + dnsServerDao.deleteAndDecrement(id) + } + + +} \ No newline at end of file diff --git a/app/src/main/java/ru/karasevm/privatednstoggle/data/DnsServerViewModel.kt b/app/src/main/java/ru/karasevm/privatednstoggle/data/DnsServerViewModel.kt new file mode 100644 index 0000000..bd1987a --- /dev/null +++ b/app/src/main/java/ru/karasevm/privatednstoggle/data/DnsServerViewModel.kt @@ -0,0 +1,48 @@ +package ru.karasevm.privatednstoggle.data + +import androidx.lifecycle.LiveData +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.asLiveData +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.launch +import ru.karasevm.privatednstoggle.model.DnsServer + +class DnsServerViewModel(private val dnsServerRepository: DnsServerRepository) : ViewModel() { + + val allServers: LiveData> = dnsServerRepository.allServers.asLiveData() + + fun getAll() = dnsServerRepository.getAll() + + suspend fun getById(id: Int) = dnsServerRepository.getById(id) + + fun insert(dnsServer: DnsServer) = + viewModelScope.launch { + dnsServerRepository.insert(dnsServer) + } + + fun update( + id: Int, + server: String? = null, + label: String? = null, + sortOrder: Int? = null, + enabled: Boolean? = null + ) = viewModelScope.launch { dnsServerRepository.update(id, server, label, sortOrder, enabled) } + + fun move(sortOrder: Int, newSortOrder: Int, id: Int) = + viewModelScope.launch { dnsServerRepository.move(sortOrder, newSortOrder, id) } + + fun delete(id: Int) = viewModelScope.launch { dnsServerRepository.delete(id) } + +} + +class DnsServerViewModelFactory(private val dnsServerRepository: DnsServerRepository) : + ViewModelProvider.Factory { + override fun create(modelClass: Class): T { + if (modelClass.isAssignableFrom(DnsServerViewModel::class.java)) { + @Suppress("UNCHECKED_CAST") + return DnsServerViewModel(dnsServerRepository) as T + } + throw IllegalArgumentException("Unknown ViewModel class") + } +} \ No newline at end of file diff --git a/app/src/main/java/ru/karasevm/privatednstoggle/data/database/DnsServerRoomDatabase.kt b/app/src/main/java/ru/karasevm/privatednstoggle/data/database/DnsServerRoomDatabase.kt new file mode 100644 index 0000000..72b6f25 --- /dev/null +++ b/app/src/main/java/ru/karasevm/privatednstoggle/data/database/DnsServerRoomDatabase.kt @@ -0,0 +1,34 @@ +package ru.karasevm.privatednstoggle.data.database + +import android.content.Context +import androidx.room.Database +import androidx.room.Room +import androidx.room.RoomDatabase +import ru.karasevm.privatednstoggle.data.DnsServerDao +import ru.karasevm.privatednstoggle.model.DnsServer + +@Database(entities = [DnsServer::class], version = 1, exportSchema = false) +abstract class DnsServerRoomDatabase : RoomDatabase() { + + abstract fun dnsServerDao(): DnsServerDao + + companion object { + @Volatile + private var INSTANCE: DnsServerRoomDatabase? = null + fun getDatabase(context: Context): DnsServerRoomDatabase { + val tempInstance = INSTANCE + if (tempInstance != null) { + return tempInstance + } + synchronized(this) { + val instance = Room.databaseBuilder( + context.applicationContext, + DnsServerRoomDatabase::class.java, + "dns_server_database" + ).build() + INSTANCE = instance + return instance + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/ru/karasevm/privatednstoggle/model/DnsServer.kt b/app/src/main/java/ru/karasevm/privatednstoggle/model/DnsServer.kt new file mode 100644 index 0000000..38c1be4 --- /dev/null +++ b/app/src/main/java/ru/karasevm/privatednstoggle/model/DnsServer.kt @@ -0,0 +1,24 @@ +package ru.karasevm.privatednstoggle.model + +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.PrimaryKey +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +// All fields must have default values for proper deserialization +@Serializable +@Entity(tableName = "dns_servers") +data class DnsServer( + @SerialName("id") + @PrimaryKey(autoGenerate = true) + val id: Int = 0, + @SerialName("server") + val server: String = "", + @SerialName("label") + val label: String = "", + @SerialName("enabled") + @ColumnInfo(defaultValue = "1") + val enabled: Boolean = true, + val sortOrder: Int? = null +) diff --git a/app/src/main/java/ru/karasevm/privatednstoggle/service/DnsTileService.kt b/app/src/main/java/ru/karasevm/privatednstoggle/service/DnsTileService.kt new file mode 100644 index 0000000..1bb1944 --- /dev/null +++ b/app/src/main/java/ru/karasevm/privatednstoggle/service/DnsTileService.kt @@ -0,0 +1,302 @@ +package ru.karasevm.privatednstoggle.service + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.graphics.drawable.Icon +import android.provider.Settings +import android.service.quicksettings.Tile +import android.service.quicksettings.TileService +import android.util.Log +import androidx.core.content.ContextCompat +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancelChildren +import kotlinx.coroutines.launch +import ru.karasevm.privatednstoggle.PrivateDNSApp +import ru.karasevm.privatednstoggle.R +import ru.karasevm.privatednstoggle.data.DnsServerRepository +import ru.karasevm.privatednstoggle.model.DnsServer +import ru.karasevm.privatednstoggle.util.PreferenceHelper +import ru.karasevm.privatednstoggle.util.PreferenceHelper.autoMode +import ru.karasevm.privatednstoggle.util.PreferenceHelper.requireUnlock +import ru.karasevm.privatednstoggle.util.PrivateDNSUtils +import ru.karasevm.privatednstoggle.util.PrivateDNSUtils.AUTO_MODE_OPTION_AUTO +import ru.karasevm.privatednstoggle.util.PrivateDNSUtils.AUTO_MODE_OPTION_OFF_AUTO +import ru.karasevm.privatednstoggle.util.PrivateDNSUtils.AUTO_MODE_OPTION_PRIVATE +import ru.karasevm.privatednstoggle.util.PrivateDNSUtils.DNS_MODE_AUTO +import ru.karasevm.privatednstoggle.util.PrivateDNSUtils.DNS_MODE_OFF +import ru.karasevm.privatednstoggle.util.PrivateDNSUtils.DNS_MODE_PRIVATE +import ru.karasevm.privatednstoggle.util.PrivateDNSUtils.checkForPermission + +class DnsTileService : TileService() { + + private val repository: DnsServerRepository by lazy { (application as PrivateDNSApp).repository } + private val job = SupervisorJob() + private val scope = CoroutineScope(Dispatchers.IO + job) + private val sharedPreferences by lazy { PreferenceHelper.defaultPreference(this) } + private var isBroadcastReceiverRegistered = false + + override fun onTileAdded() { + super.onTileAdded() + checkForPermission(this) + // Update state + qsTile.state = Tile.STATE_INACTIVE + + // Update looks + qsTile.updateTile() + } + + /** + * Set's the state of the tile and system settings to the next state + */ + private fun cycleState() { + val dnsMode = Settings.Global.getString(contentResolver, "private_dns_mode") + val dnsProvider = Settings.Global.getString(contentResolver, "private_dns_specifier") + + if (dnsMode.equals(DNS_MODE_OFF, ignoreCase = true)) { + if (sharedPreferences.autoMode == AUTO_MODE_OPTION_AUTO || sharedPreferences.autoMode == AUTO_MODE_OPTION_OFF_AUTO) { + changeDNSServer(DNS_MODE_AUTO, dnsProvider) + } else { + changeDNSServer(DNS_MODE_PRIVATE, dnsProvider) + } + + } else if (dnsMode == null || dnsMode.equals(DNS_MODE_AUTO, ignoreCase = true)) { + changeDNSServer(DNS_MODE_PRIVATE, null) + } else if (dnsMode.equals(DNS_MODE_PRIVATE, ignoreCase = true)) { + scope.launch { + if (getNextAddress(dnsProvider) == null) { + if (sharedPreferences.autoMode == AUTO_MODE_OPTION_PRIVATE) { + changeDNSServer(DNS_MODE_PRIVATE, null) + } else { + if (sharedPreferences.autoMode == AUTO_MODE_OPTION_AUTO) { + changeDNSServer(DNS_MODE_AUTO, dnsProvider) + } else { + changeDNSServer(DNS_MODE_OFF, dnsProvider) + } + } + } else { + changeDNSServer(DNS_MODE_PRIVATE, dnsProvider) + } + } + } + } + + /** + * Sets the state of the tile to the provided values + * @param mode dns mode + * @param dnsProvider dns provider + */ + private fun changeDNSServer(mode: String, dnsProvider: String?) { + when (mode) { + DNS_MODE_OFF -> { + changeTileState( + qsTile, + Tile.STATE_INACTIVE, + getString(R.string.dns_off), + R.drawable.ic_off_black_24dp, + DNS_MODE_OFF, + null + ) + } + + DNS_MODE_AUTO -> { + changeTileState( + qsTile, + Tile.STATE_INACTIVE, + getString(R.string.dns_auto), + R.drawable.ic_auto_black_24dp, + DNS_MODE_AUTO, + dnsProvider + ) + } + + DNS_MODE_PRIVATE -> { + scope.launch { + val nextDnsServer = getNextAddress(dnsProvider) + if (nextDnsServer != null) { + changeTileState( + qsTile, + Tile.STATE_ACTIVE, + nextDnsServer.label.ifEmpty { nextDnsServer.server }, + R.drawable.ic_private_black_24dp, + DNS_MODE_PRIVATE, + getNextAddress(dnsProvider)?.server + ) + } + } + } + } + } + + override fun onClick() { + super.onClick() + if (!checkForPermission(this)) { + return + } + + // Require unlock to change mode according to user preference + val requireUnlock = sharedPreferences.requireUnlock + if (isLocked && requireUnlock) { + unlockAndRun(this::cycleState) + } else { + cycleState() + } + + + } + + /** + * Refreshes the state of the tile + */ + private fun refreshTile() { + val isPermissionGranted = checkForPermission(this) + val dnsMode = Settings.Global.getString(contentResolver, "private_dns_mode") + when (dnsMode?.lowercase()) { + DNS_MODE_OFF -> { + setTile( + qsTile, + if (!isPermissionGranted) Tile.STATE_UNAVAILABLE else Tile.STATE_INACTIVE, + getString(R.string.dns_off), + R.drawable.ic_off_black_24dp + ) + } + + DNS_MODE_AUTO -> { + setTile( + qsTile, + if (!isPermissionGranted) Tile.STATE_UNAVAILABLE else Tile.STATE_INACTIVE, + getString(R.string.dns_auto), + R.drawable.ic_auto_black_24dp + ) + } + + DNS_MODE_PRIVATE -> { + scope.launch { + val activeAddress = + Settings.Global.getString(contentResolver, "private_dns_specifier") + val dnsServer = repository.getFirstByServer(activeAddress) + setTile( + qsTile, + if (!isPermissionGranted) Tile.STATE_UNAVAILABLE else Tile.STATE_ACTIVE, + // display server address if either there is no label or the server is not known + dnsServer?.label?.ifBlank { activeAddress } ?: activeAddress, + R.drawable.ic_private_black_24dp + ) + } + } + + else -> { + setTile( + qsTile, + if (!isPermissionGranted) Tile.STATE_UNAVAILABLE else Tile.STATE_INACTIVE, + getString(R.string.dns_unknown), + R.drawable.ic_unknown_black_24dp + ) + } + } + } + + private val broadcastReceiver = object : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + refreshTile() + } + } + + override fun onStartListening() { + super.onStartListening() + + // Prevent some crashes + if (qsTile == null) { + Log.w(TAG, "onStartListening: qsTile is null") + return + } + + + // Receive broadcasts to update the tile when server is changed from the dialog + ContextCompat.registerReceiver( + this, + broadcastReceiver, + IntentFilter("refresh_tile"), + ContextCompat.RECEIVER_NOT_EXPORTED + ) + isBroadcastReceiverRegistered = true + refreshTile() + } + + override fun onStopListening() { + super.onStopListening() + if (isBroadcastReceiverRegistered) { + unregisterReceiver(broadcastReceiver) + isBroadcastReceiverRegistered = false + } + + } + + override fun onDestroy() { + super.onDestroy() + job.cancelChildren() + } + + /** + * Updates tile to specified parameters + * + * @param tile tile to update + * @param state tile state + * @param label tile label + * @param icon tile icon + */ + private fun setTile(tile: Tile, state: Int, label: String?, icon: Int) { + tile.state = state + tile.label = label + tile.icon = Icon.createWithResource(this, icon) + tile.updateTile() + } + + /** + * Updates tile and system settings to specified parameters + * + * @param tile tile to update + * @param state tile state + * @param label tile label + * @param icon tile icon + * @param dnsMode system dns mode + * @param dnsProvider system dns provider + */ + private fun changeTileState( + tile: Tile, + state: Int, + label: String?, + icon: Int, + dnsMode: String, + dnsProvider: String? + ) { + tile.label = label + tile.state = state + tile.icon = Icon.createWithResource(this, icon) + PrivateDNSUtils.setPrivateMode(contentResolver, dnsMode) + PrivateDNSUtils.setPrivateProvider(contentResolver, dnsProvider) + tile.updateTile() + } + + /** + * Gets next dns address from the database, + * if current address is last or unknown returns null + * + * @param currentAddress currently set address + * @return next address + */ + private suspend fun getNextAddress(currentAddress: String?): DnsServer? { + return if (currentAddress.isNullOrEmpty()) { + repository.getFirstEnabled() + } else { + repository.getNextByServer(currentAddress) + } + } + + companion object { + private const val TAG = "DnsTileService" + } +} diff --git a/app/src/main/java/ru/karasevm/privatednstoggle/ui/AddServerDialogFragment.kt b/app/src/main/java/ru/karasevm/privatednstoggle/ui/AddServerDialogFragment.kt new file mode 100644 index 0000000..f5440d9 --- /dev/null +++ b/app/src/main/java/ru/karasevm/privatednstoggle/ui/AddServerDialogFragment.kt @@ -0,0 +1,140 @@ +package ru.karasevm.privatednstoggle.ui + +import android.app.Dialog +import android.content.Context +import android.content.DialogInterface +import android.os.Bundle +import android.text.Editable +import android.text.TextUtils +import android.text.TextWatcher +import androidx.appcompat.app.AlertDialog +import androidx.fragment.app.DialogFragment +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import com.google.common.net.InternetDomainName +import ru.karasevm.privatednstoggle.R +import ru.karasevm.privatednstoggle.databinding.DialogAddBinding +import ru.karasevm.privatednstoggle.model.DnsServer + + +class AddServerDialogFragment( + private val dnsServer: DnsServer? +) : DialogFragment() { + // Use this instance of the interface to deliver action events + private lateinit var listener: NoticeDialogListener + + private var _binding: DialogAddBinding? = null + + // This property is only valid between onCreateView and + // onDestroyView. + private val binding get() = _binding!! + + /* The activity that creates an instance of this dialog fragment must + * implement this interface in order to receive event callbacks. + * Each method passes the DialogFragment in case the host needs to query it. */ + interface NoticeDialogListener { + fun onAddDialogPositiveClick(label: String?, server: String) + fun onUpdateDialogPositiveClick(id: Int, server: String, label: String?, enabled: Boolean) + fun onDeleteItemClicked(id: Int) + } + + // Override the Fragment.onAttach() method to instantiate the NoticeDialogListener + override fun onAttach(context: Context) { + super.onAttach(context) + // Verify that the host activity implements the callback interface + try { + // Instantiate the NoticeDialogListener so we can send events to the host + listener = context as NoticeDialogListener + } catch (e: ClassCastException) { + // The activity doesn't implement the interface, throw exception + throw ClassCastException( + (context.toString() + + " must implement NoticeDialogListener") + ) + } + } + + override fun onCreateDialog( + savedInstanceState: Bundle? + ): Dialog { + return activity?.let { + val builder = MaterialAlertDialogBuilder(it) + // Get the layout inflater + val inflater = requireActivity().layoutInflater + _binding = DialogAddBinding.inflate(inflater) + + val view = binding.root + // Inflate and set the layout for the dialog + // Pass null as the parent view because its going in the dialog layout + if (dnsServer != null) { + binding.editTextServerHint.setText(dnsServer.label) + binding.editTextServerAddr.setText(dnsServer.server) + binding.serverEnabledSwitch.visibility = android.view.View.VISIBLE + binding.serverEnabledSwitch.isChecked = dnsServer.enabled + builder.setTitle(R.string.edit_server).setView(view) + .setPositiveButton( + R.string.menu_save + ) { _, _ -> + listener.onUpdateDialogPositiveClick( + dnsServer.id, + binding.editTextServerAddr.text.toString().trim(), + binding.editTextServerHint.text.toString().trim(), + binding.serverEnabledSwitch.isChecked + ) + } + .setNegativeButton( + R.string.cancel + ) { _, _ -> + dialog?.cancel() + } + .setNeutralButton( + R.string.delete + ) { _, _ -> + listener.onDeleteItemClicked(dnsServer.id) + } + } else { + builder.setTitle(R.string.add_server) + .setView(view) + // Add action buttons + .setPositiveButton( + R.string.menu_add + ) { _, _ -> + listener.onAddDialogPositiveClick( + binding.editTextServerHint.text.toString().trim(), + binding.editTextServerAddr.text.toString().trim() + ) + } + .setNegativeButton( + R.string.cancel + ) { _, _ -> + dialog?.cancel() + } + } + builder.create() + } ?: throw IllegalStateException("Activity cannot be null") + } + + override fun onStart() { + super.onStart() + val button = ((dialog) as AlertDialog).getButton(DialogInterface.BUTTON_POSITIVE) + binding.editTextServerAddr.addTextChangedListener(object : TextWatcher { + override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {} + + override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {} + + override fun afterTextChanged(s: Editable?) { + val server = binding.editTextServerAddr.text.toString().trim() + if (TextUtils.isEmpty(server) || !isValidServer(server)) { + button.isEnabled = false + } else { + binding.editTextServerAddr.error = null + button.isEnabled = true + } + } + }) + } + + private fun isValidServer(str: String): Boolean { + return InternetDomainName.isValid(str) + } + +} \ No newline at end of file diff --git a/app/src/main/java/ru/karasevm/privatednstoggle/ui/DNSServerDialogFragment.kt b/app/src/main/java/ru/karasevm/privatednstoggle/ui/DNSServerDialogFragment.kt new file mode 100644 index 0000000..b2fa284 --- /dev/null +++ b/app/src/main/java/ru/karasevm/privatednstoggle/ui/DNSServerDialogFragment.kt @@ -0,0 +1,140 @@ +package ru.karasevm.privatednstoggle.ui + +import android.app.Dialog +import android.content.Intent +import android.os.Bundle +import android.widget.Toast +import androidx.fragment.app.DialogFragment +import androidx.fragment.app.viewModels +import androidx.lifecycle.lifecycleScope +import androidx.recyclerview.widget.LinearLayoutManager +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import kotlinx.coroutines.launch +import ru.karasevm.privatednstoggle.PrivateDNSApp +import ru.karasevm.privatednstoggle.R +import ru.karasevm.privatednstoggle.data.DnsServerViewModel +import ru.karasevm.privatednstoggle.data.DnsServerViewModelFactory +import ru.karasevm.privatednstoggle.databinding.SheetDnsSelectorBinding +import ru.karasevm.privatednstoggle.model.DnsServer +import ru.karasevm.privatednstoggle.util.PrivateDNSUtils +import ru.karasevm.privatednstoggle.util.PrivateDNSUtils.checkForPermission + +class DNSServerDialogFragment : DialogFragment() { + + private var _binding: SheetDnsSelectorBinding? = null + private val binding get() = _binding!! + + private lateinit var linearLayoutManager: LinearLayoutManager + private lateinit var adapter: ServerListRecyclerAdapter + private var servers: MutableList = mutableListOf() + private val dnsServerViewModel: DnsServerViewModel by viewModels { DnsServerViewModelFactory((requireActivity().application as PrivateDNSApp).repository) } + private val contentResolver by lazy { requireActivity().contentResolver } + + override fun onCreateDialog( + savedInstanceState: Bundle? + ): Dialog { + return activity?.let { + val startIntent = Intent(context, MainActivity::class.java) + + val builder = MaterialAlertDialogBuilder(it) + val inflater = requireActivity().layoutInflater + _binding = SheetDnsSelectorBinding.inflate(inflater) + linearLayoutManager = LinearLayoutManager(context) + binding.recyclerView.layoutManager = linearLayoutManager + + adapter = ServerListRecyclerAdapter(false) + binding.recyclerView.adapter = adapter + lifecycleScope.launch { + dnsServerViewModel.getAll().collect { s -> + servers = s.toMutableList() + if (servers.isEmpty()) { + servers.add(DnsServer(0, "dns.google")) + } + servers.add(0, DnsServer(-1, resources.getString(R.string.dns_auto))) + servers.add(0, DnsServer(-2, resources.getString(R.string.dns_off))) + adapter.submitList(servers) + } + } + builder.setTitle(R.string.select_server) + .setView(binding.root) + .setPositiveButton( + R.string.done + ) { _, _ -> + dialog?.dismiss() + } + .setNeutralButton(R.string.open_app) { _, _ -> context?.startActivity(startIntent) } + builder.create() + } ?: throw IllegalStateException("Activity cannot be null") + } + + override fun onStart() { + super.onStart() + if (!checkForPermission(requireContext())) { + Toast.makeText( + context, R.string.permission_missing, Toast.LENGTH_SHORT + ).show() + dialog!!.dismiss() + } + adapter.onItemClick = { id -> + when (id) { + OFF_ID -> { + PrivateDNSUtils.setPrivateMode( + contentResolver, + PrivateDNSUtils.DNS_MODE_OFF + ) + PrivateDNSUtils.setPrivateProvider( + contentResolver, + null) + Toast.makeText(context, R.string.set_to_off_toast, Toast.LENGTH_SHORT).show() + } + + AUTO_ID -> { + PrivateDNSUtils.setPrivateMode( + contentResolver, + PrivateDNSUtils.DNS_MODE_AUTO + ) + PrivateDNSUtils.setPrivateProvider( + contentResolver, + null) + Toast.makeText(context, R.string.set_to_auto_toast, Toast.LENGTH_SHORT).show() + } + + else -> { + lifecycleScope.launch { + val server = servers.find { server -> server.id == id } + PrivateDNSUtils.setPrivateMode( + contentResolver, + PrivateDNSUtils.DNS_MODE_PRIVATE + ) + PrivateDNSUtils.setPrivateProvider( + contentResolver, + server?.server + ) + Toast.makeText( + context, + getString( + R.string.set_to_provider_toast, + server?.label?.ifEmpty { server.server } + ), + Toast.LENGTH_SHORT + ).show() + } + } + } + dialog?.dismiss() + requireContext().sendBroadcast(Intent("refresh_tile").setPackage(requireContext().packageName)) + } + + } + + override fun onDestroy() { + super.onDestroy() + activity?.finish() + } + + companion object { + const val TAG = "DNSServerDialogFragment" + private const val AUTO_ID = -1 + private const val OFF_ID = -2 + } +} \ No newline at end of file diff --git a/app/src/main/java/ru/karasevm/privatednstoggle/DeleteServerDialogFragment.kt b/app/src/main/java/ru/karasevm/privatednstoggle/ui/DeleteServerDialogFragment.kt similarity index 64% rename from app/src/main/java/ru/karasevm/privatednstoggle/DeleteServerDialogFragment.kt rename to app/src/main/java/ru/karasevm/privatednstoggle/ui/DeleteServerDialogFragment.kt index 527324d..a6a2d98 100644 --- a/app/src/main/java/ru/karasevm/privatednstoggle/DeleteServerDialogFragment.kt +++ b/app/src/main/java/ru/karasevm/privatednstoggle/ui/DeleteServerDialogFragment.kt @@ -1,13 +1,14 @@ -package ru.karasevm.privatednstoggle +package ru.karasevm.privatednstoggle.ui import android.app.Dialog import android.content.Context import android.os.Bundle -import androidx.appcompat.app.AlertDialog import androidx.fragment.app.DialogFragment +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import ru.karasevm.privatednstoggle.R -class DeleteServerDialogFragment(private val position: Int): DialogFragment() { +class DeleteServerDialogFragment(private val id: Int) : DialogFragment() { // Use this instance of the interface to deliver action events private lateinit var listener: NoticeDialogListener @@ -15,7 +16,7 @@ class DeleteServerDialogFragment(private val position: Int): DialogFragment() { * implement this interface in order to receive event callbacks. * Each method passes the DialogFragment in case the host needs to query it. */ interface NoticeDialogListener { - fun onDialogPositiveClick(dialog: DialogFragment, position: Int) + fun onDeleteDialogPositiveClick(id: Int) } // Override the Fragment.onAttach() method to instantiate the NoticeDialogListener @@ -27,20 +28,26 @@ class DeleteServerDialogFragment(private val position: Int): DialogFragment() { listener = context as NoticeDialogListener } catch (e: ClassCastException) { // The activity doesn't implement the interface, throw exception - throw ClassCastException((context.toString() + - " must implement NoticeDialogListener")) + throw ClassCastException( + (context.toString() + + " must implement NoticeDialogListener") + ) } } + override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { return activity?.let { - val builder = AlertDialog.Builder(it) + val builder = MaterialAlertDialogBuilder(it) - builder.setMessage(R.string.delete_question) - .setPositiveButton(R.string.delete + builder.setTitle(R.string.delete_question) + .setMessage(R.string.delete_message) + .setPositiveButton( + R.string.delete ) { _, _ -> - listener.onDialogPositiveClick(this, position) + listener.onDeleteDialogPositiveClick(id) } - .setNegativeButton(R.string.cancel + .setNegativeButton( + R.string.cancel ) { _, _ -> dialog?.cancel() } diff --git a/app/src/main/java/ru/karasevm/privatednstoggle/ui/MainActivity.kt b/app/src/main/java/ru/karasevm/privatednstoggle/ui/MainActivity.kt new file mode 100644 index 0000000..26695a3 --- /dev/null +++ b/app/src/main/java/ru/karasevm/privatednstoggle/ui/MainActivity.kt @@ -0,0 +1,480 @@ +package ru.karasevm.privatednstoggle.ui + +import android.Manifest +import android.content.ClipData +import android.content.ClipDescription.MIMETYPE_TEXT_PLAIN +import android.content.ClipboardManager +import android.content.Intent +import android.content.SharedPreferences +import android.content.pm.PackageManager +import android.graphics.Color +import android.net.Uri +import android.os.Build +import android.os.Bundle +import android.util.Log +import android.view.Menu +import android.view.View +import android.widget.Toast +import androidx.activity.result.contract.ActivityResultContracts +import androidx.activity.viewModels +import androidx.appcompat.app.AppCompatActivity +import androidx.core.app.ShareCompat +import androidx.lifecycle.viewModelScope +import androidx.recyclerview.widget.ItemTouchHelper +import androidx.recyclerview.widget.ItemTouchHelper.DOWN +import androidx.recyclerview.widget.ItemTouchHelper.UP +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import kotlinx.coroutines.launch +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import rikka.shizuku.Shizuku +import rikka.shizuku.ShizukuProvider +import ru.karasevm.privatednstoggle.PrivateDNSApp +import ru.karasevm.privatednstoggle.R +import ru.karasevm.privatednstoggle.data.DnsServerViewModel +import ru.karasevm.privatednstoggle.data.DnsServerViewModelFactory +import ru.karasevm.privatednstoggle.databinding.ActivityMainBinding +import ru.karasevm.privatednstoggle.model.DnsServer +import ru.karasevm.privatednstoggle.util.BackupUtils +import ru.karasevm.privatednstoggle.util.PreferenceHelper +import ru.karasevm.privatednstoggle.util.PreferenceHelper.dns_servers +import ru.karasevm.privatednstoggle.util.ShizukuUtil.grantPermissionWithShizuku + + +class MainActivity : AppCompatActivity(), AddServerDialogFragment.NoticeDialogListener, + DeleteServerDialogFragment.NoticeDialogListener, Shizuku.OnRequestPermissionResultListener { + + private lateinit var linearLayoutManager: LinearLayoutManager + private lateinit var binding: ActivityMainBinding + private lateinit var sharedPrefs: SharedPreferences + private lateinit var adapter: ServerListRecyclerAdapter + private lateinit var clipboard: ClipboardManager + private val dnsServerViewModel: DnsServerViewModel by viewModels { DnsServerViewModelFactory((application as PrivateDNSApp).repository) } + + private val itemTouchHelper by lazy { + val simpleItemTouchCallback = + object : ItemTouchHelper.SimpleCallback(UP or DOWN, 0) { + var dragFrom = -1 + var dragTo = -1 + + override fun onMove( + recyclerView: RecyclerView, + viewHolder: RecyclerView.ViewHolder, + target: RecyclerView.ViewHolder + ): Boolean { + if (dragFrom == viewHolder.bindingAdapterPosition && dragTo == target.bindingAdapterPosition) { + return true + } + // store the drag position + if (dragFrom == -1) dragFrom = viewHolder.bindingAdapterPosition + dragTo = target.bindingAdapterPosition + adapter.onItemMove( + viewHolder.bindingAdapterPosition, + target.bindingAdapterPosition + ) + return true + } + + override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) {} + + override fun onSelectedChanged( + viewHolder: RecyclerView.ViewHolder?, actionState: Int + ) { + super.onSelectedChanged(viewHolder, actionState) + if (actionState == ItemTouchHelper.ACTION_STATE_DRAG) { + viewHolder?.itemView?.apply { + // Example: Elevate the view + elevation = 8f + alpha = 0.5f + setBackgroundColor(Color.GRAY) + } + } + } + + override fun clearView( + recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder + ) { + super.clearView(recyclerView, viewHolder) + viewHolder.itemView.apply { + // Reset the appearance + elevation = 0f + alpha = 1.0f + setBackgroundColor(Color.TRANSPARENT) + } + // commit the change to the db + dnsServerViewModel.move( + dragFrom, + dragTo, + (viewHolder as ServerListRecyclerAdapter.DnsServerViewHolder).id + ) + dragTo = -1 + dragFrom = -1 + } + } + ItemTouchHelper(simpleItemTouchCallback) + } + + private fun importSettings(json: String) { + runCatching { + val data: BackupUtils.Backup = Json.decodeFromString(json) + BackupUtils.import(data, dnsServerViewModel, sharedPrefs) + }.onSuccess { + Toast.makeText( + this, getString(R.string.import_success), Toast.LENGTH_SHORT + ).show() + }.onFailure { exception -> + runCatching { + Log.e("IMPORT", "Malformed json, falling back to legacy", exception) + val data = Json.decodeFromString(json) + BackupUtils.importLegacy(data, dnsServerViewModel, sharedPrefs) + }.onSuccess { + Toast.makeText( + this, getString(R.string.import_success), Toast.LENGTH_SHORT + ).show() + }.onFailure { exception -> + Log.e("IMPORT", "Import failed", exception) + Toast.makeText( + this, getString(R.string.import_failure), Toast.LENGTH_SHORT + ).show() + } + } + } + + /** + * Migrate the SharedPreferences server list to Room + */ + private fun migrateServerList() { + dnsServerViewModel.viewModelScope.launch { + if (sharedPrefs.dns_servers.isNotEmpty() && sharedPrefs.dns_servers[0] != "") { + Log.i( + "migrate", + "existing sharedPrefs list: ${sharedPrefs.dns_servers} ${sharedPrefs.dns_servers.size}" + ) + sharedPrefs.dns_servers.forEach { server -> + val parts = server.split(" : ").toMutableList() + if (parts.size != 2) parts.add(0, "") + Log.i("migrate", "migrating: $server -> $parts") + dnsServerViewModel.insert(DnsServer(0, parts[1], parts[0])) + } + sharedPrefs.dns_servers = emptyList().toMutableList() + } + } + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + Shizuku.addRequestPermissionResultListener(this::onRequestPermissionResult) + + binding = ActivityMainBinding.inflate(layoutInflater) + val view = binding.root + setContentView(view) + + linearLayoutManager = LinearLayoutManager(this) + binding.recyclerView.layoutManager = linearLayoutManager + + sharedPrefs = PreferenceHelper.defaultPreference(this) + clipboard = getSystemService(CLIPBOARD_SERVICE) as ClipboardManager + + migrateServerList() + + adapter = ServerListRecyclerAdapter(true) + binding.recyclerView.adapter = adapter + + dnsServerViewModel.allServers.observe(this) { servers -> + adapter.submitList(servers) + if (servers.isEmpty()) { + binding.emptyView.visibility = View.VISIBLE + binding.emptyViewHint.visibility = View.VISIBLE + } else { + binding.emptyView.visibility = View.GONE + binding.emptyViewHint.visibility = View.GONE + } + } + adapter.onItemClick = { id -> + dnsServerViewModel.viewModelScope.launch { + val server = dnsServerViewModel.getById(id) + if (server != null) { + val newFragment = + AddServerDialogFragment(server) + newFragment.show(supportFragmentManager, "edit_server") + } + } + } + adapter.onDragStart = { viewHolder -> + itemTouchHelper.startDrag(viewHolder) + } + binding.floatingActionButton.setOnClickListener { + val newFragment = AddServerDialogFragment(null) + newFragment.show(supportFragmentManager, "add_server") + } + binding.recyclerView.adapter = adapter + itemTouchHelper.attachToRecyclerView(binding.recyclerView) + + binding.topAppBar.setOnMenuItemClickListener { item -> + when (item.itemId) { + R.id.privacy_policy -> { + val browserIntent = Intent( + Intent.ACTION_VIEW, + Uri.parse("https://karasevm.github.io/PrivateDNSAndroid/privacy_policy") + ) + startActivity(browserIntent) + true + } + + R.id.export_settings_clipboard -> { + dnsServerViewModel.viewModelScope.launch { + val data = BackupUtils.export(dnsServerViewModel, sharedPrefs) + val jsonData = Json.encodeToString(data) + clipboard.setPrimaryClip(ClipData.newPlainText("", jsonData)) + // Only show a toast for Android 12 and lower. + if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.S_V2) Toast.makeText( + applicationContext, getString(R.string.copy_success), Toast.LENGTH_SHORT + ).show() + } + true + } + + R.id.export_settings_share -> { + val activityContext = this + dnsServerViewModel.viewModelScope.launch { + val data = BackupUtils.export(dnsServerViewModel, sharedPrefs) + val jsonData = Json.encodeToString(data) + ShareCompat.IntentBuilder(activityContext).setText(jsonData) + .setType("text/plain") + .startChooser() + } + true + } + + R.id.export_settings_file -> { + + dnsServerViewModel.viewModelScope.launch { + val intent = Intent(Intent.ACTION_CREATE_DOCUMENT).apply { + addCategory(Intent.CATEGORY_OPENABLE) + type = "text/plain" + putExtra(Intent.EXTRA_TITLE, "private-dns-export") + } + saveResultLauncher.launch(intent) + } + true + } + + R.id.import_settings_file -> { + val intent = Intent(Intent.ACTION_OPEN_DOCUMENT).apply { + addCategory(Intent.CATEGORY_OPENABLE) + type = "text/plain" + } + importResultLauncher.launch(intent) + true + } + + R.id.import_settings_clipboard -> { + val clipData = clipboard.primaryClip?.getItemAt(0) + val textData = clipData?.text + + if (textData != null) { + importSettings(textData.toString()) + } + true + } + + R.id.options -> { + val newFragment = OptionsDialogFragment() + newFragment.show(supportFragmentManager, "options") + true + } + + else -> true + } + } + } + + private var saveResultLauncher = + registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> + if (result.resultCode == RESULT_OK) { + val data: Intent? = result.data + data?.data?.also { uri -> + val jsonData = Json.encodeToString(BackupUtils.export(dnsServerViewModel, sharedPrefs)) + val contentResolver = applicationContext.contentResolver + runCatching { + contentResolver.openOutputStream(uri)?.use { outputStream -> + outputStream.write(jsonData.toByteArray()) + } + }.onFailure { exception -> + Log.e("EXPORT", "Export failed", exception) + Toast.makeText( + this, getString(R.string.export_failure), Toast.LENGTH_SHORT + ).show() + }.onSuccess { + Toast.makeText( + this, getString(R.string.export_success), Toast.LENGTH_SHORT + ).show() + } + } + } + } + + private var importResultLauncher = + registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> + if (result.resultCode == RESULT_OK) { + val data: Intent? = result.data + data?.data?.also { uri -> + val contentResolver = applicationContext.contentResolver + contentResolver.openInputStream(uri)?.use { inputStream -> + val jsonData = inputStream.bufferedReader().use { it.readText() } + importSettings(jsonData) + } + } + } + } + + override fun onCreateOptionsMenu(menu: Menu?): Boolean { + menuInflater.inflate(R.menu.menu_main, menu) + return true + } + + override fun onResume() { + super.onResume() + // Check if WRITE_SECURE_SETTINGS is granted + if (checkSelfPermission(Manifest.permission.WRITE_SECURE_SETTINGS) != PackageManager.PERMISSION_GRANTED) { + // Check if Shizuku is available + if (Shizuku.pingBinder()) { + // check if permission is granted already + val isGranted = if (Shizuku.isPreV11() || Shizuku.getVersion() < 11) { + checkSelfPermission(ShizukuProvider.PERMISSION) == PackageManager.PERMISSION_GRANTED + } else { + Shizuku.checkSelfPermission() == PackageManager.PERMISSION_GRANTED + } + // request permission if not granted + if (!isGranted && !Shizuku.shouldShowRequestPermissionRationale()) { + if (Shizuku.isPreV11() || Shizuku.getVersion() < 11) { + requestPermissions(arrayOf(ShizukuProvider.PERMISSION), 1) + } else { + Shizuku.requestPermission(1) + } + } else { + grantPermission() + } + } else { + if (checkSelfPermission(Manifest.permission.WRITE_SECURE_SETTINGS) != PackageManager.PERMISSION_GRANTED) { + val browserIntent = Intent( + Intent.ACTION_VIEW, + Uri.parse("https://karasevm.github.io/PrivateDNSAndroid/") + ) + Toast.makeText( + this, R.string.shizuku_failure_toast, Toast.LENGTH_SHORT + ).show() + startActivity(browserIntent) + finish() + } + } + } + } + + override fun onWindowFocusChanged(hasFocus: Boolean) { + super.onWindowFocusChanged(hasFocus) + if (!hasFocus) { + // Gets the ID of the "paste" menu item. + val pasteItem = binding.topAppBar.menu.findItem(R.id.import_settings_clipboard) + + // If the clipboard doesn't contain data, disable the paste menu item. + // If it does contain data, decide whether you can handle the data. + pasteItem.isEnabled = when { + !clipboard.hasPrimaryClip() -> false + !(clipboard.primaryClipDescription?.hasMimeType(MIMETYPE_TEXT_PLAIN))!! -> false + else -> true + + } + } + } + + override fun onDestroy() { + super.onDestroy() + Shizuku.removeRequestPermissionResultListener(this::onRequestPermissionResult) + } + + /** + * Show the dialog for deleting the server + * @param id The server id + */ + override fun onDeleteItemClicked(id: Int) { + val newFragment = DeleteServerDialogFragment(id) + newFragment.show(supportFragmentManager, "delete_server") + } + + /** + * Callback for adding the server + * @param label The label + * @param server The server + */ + override fun onAddDialogPositiveClick(label: String?, server: String) { + if (server.isEmpty()) { + Toast.makeText(this, R.string.server_length_error, Toast.LENGTH_SHORT).show() + return + } + + if (label.isNullOrEmpty()) { + dnsServerViewModel.insert(DnsServer(0, server)) + } else { + dnsServerViewModel.insert(DnsServer(0, server, label)) + } + } + + /** + * Callback for deleting the server + * @param id The server id + */ + override fun onDeleteDialogPositiveClick(id: Int) { + dnsServerViewModel.delete(id) + } + + /** + * Callback for updating the server + * @param label New label + * @param server New server address + * @param id The server id + */ + override fun onUpdateDialogPositiveClick( + id: Int, + server: String, + label: String?, + enabled: Boolean + ) { + if (server.isEmpty()) { + Toast.makeText(this, R.string.server_length_error, Toast.LENGTH_SHORT).show() + return + } + dnsServerViewModel.update(id, server, label, null, enabled) + } + + private fun grantPermission() { + if (grantPermissionWithShizuku(this)) { + Toast.makeText( + this, R.string.shizuku_success_toast, Toast.LENGTH_SHORT + ).show() + } else { + Toast.makeText( + this, R.string.shizuku_failure_toast, Toast.LENGTH_SHORT + ).show() + val browserIntent = Intent( + Intent.ACTION_VIEW, Uri.parse("https://karasevm.github.io/PrivateDNSAndroid/") + ) + startActivity(browserIntent) + finish() + } + } + + override fun onRequestPermissionResult(requestCode: Int, grantResult: Int) { + val isGranted = grantResult == PackageManager.PERMISSION_GRANTED + + if (!isGranted && checkSelfPermission(Manifest.permission.WRITE_SECURE_SETTINGS) != PackageManager.PERMISSION_GRANTED) { + val browserIntent = Intent( + Intent.ACTION_VIEW, Uri.parse("https://karasevm.github.io/PrivateDNSAndroid/") + ) + startActivity(browserIntent) + finish() + } + } +} \ No newline at end of file diff --git a/app/src/main/java/ru/karasevm/privatednstoggle/ui/OptionsDialogFragment.kt b/app/src/main/java/ru/karasevm/privatednstoggle/ui/OptionsDialogFragment.kt new file mode 100644 index 0000000..2ca7b14 --- /dev/null +++ b/app/src/main/java/ru/karasevm/privatednstoggle/ui/OptionsDialogFragment.kt @@ -0,0 +1,63 @@ +package ru.karasevm.privatednstoggle.ui + +import android.app.Dialog +import android.os.Bundle +import androidx.fragment.app.DialogFragment +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import ru.karasevm.privatednstoggle.R +import ru.karasevm.privatednstoggle.databinding.DialogOptionsBinding +import ru.karasevm.privatednstoggle.util.PreferenceHelper +import ru.karasevm.privatednstoggle.util.PreferenceHelper.autoMode +import ru.karasevm.privatednstoggle.util.PreferenceHelper.requireUnlock +import ru.karasevm.privatednstoggle.util.PrivateDNSUtils + +class OptionsDialogFragment : DialogFragment() { + private var _binding: DialogOptionsBinding? = null + private val binding get() = _binding!! + private val sharedPreferences by lazy { PreferenceHelper.defaultPreference(requireContext()) } + + override fun onCreateDialog( + savedInstanceState: Bundle? + ): Dialog { + return activity?.let { + val builder = MaterialAlertDialogBuilder(it) + val inflater = requireActivity().layoutInflater + _binding = DialogOptionsBinding.inflate(inflater) + + val view = binding.root + builder.setTitle(R.string.options) + .setView(view) + .setPositiveButton(R.string.ok, null) + builder.create() + + } ?: throw IllegalStateException("Activity cannot be null") + } + + override fun onStart() { + super.onStart() + val autoModeOption = sharedPreferences.autoMode + when (autoModeOption) { + PrivateDNSUtils.AUTO_MODE_OPTION_OFF -> binding.autoOptionRadioGroup.check(R.id.autoOptionOff) + PrivateDNSUtils.AUTO_MODE_OPTION_AUTO -> binding.autoOptionRadioGroup.check(R.id.autoOptionAuto) + PrivateDNSUtils.AUTO_MODE_OPTION_OFF_AUTO -> binding.autoOptionRadioGroup.check(R.id.autoOptionOffAuto) + PrivateDNSUtils.AUTO_MODE_OPTION_PRIVATE -> binding.autoOptionRadioGroup.check(R.id.autoOptionPrivate) + } + binding.autoOptionRadioGroup.setOnCheckedChangeListener { _, checkedId -> + when (checkedId) { + R.id.autoOptionOff -> sharedPreferences.autoMode = PrivateDNSUtils.AUTO_MODE_OPTION_OFF + R.id.autoOptionAuto -> sharedPreferences.autoMode = PrivateDNSUtils.AUTO_MODE_OPTION_AUTO + R.id.autoOptionOffAuto -> sharedPreferences.autoMode = + PrivateDNSUtils.AUTO_MODE_OPTION_OFF_AUTO + + R.id.autoOptionPrivate -> sharedPreferences.autoMode = + PrivateDNSUtils.AUTO_MODE_OPTION_PRIVATE + } + } + + val requireUnlock = sharedPreferences.requireUnlock + binding.requireUnlockSwitch.isChecked = requireUnlock + binding.requireUnlockSwitch.setOnCheckedChangeListener { _, isChecked -> + sharedPreferences.requireUnlock = isChecked + } + } +} \ No newline at end of file diff --git a/app/src/main/java/ru/karasevm/privatednstoggle/ui/ServerListRecyclerAdapter.kt b/app/src/main/java/ru/karasevm/privatednstoggle/ui/ServerListRecyclerAdapter.kt new file mode 100644 index 0000000..fec1eb0 --- /dev/null +++ b/app/src/main/java/ru/karasevm/privatednstoggle/ui/ServerListRecyclerAdapter.kt @@ -0,0 +1,124 @@ +package ru.karasevm.privatednstoggle.ui + +import android.annotation.SuppressLint +import android.view.LayoutInflater +import android.view.MotionEvent +import android.view.View +import android.view.ViewGroup +import android.widget.ImageView +import android.widget.TextView +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.RecyclerView +import ru.karasevm.privatednstoggle.R +import ru.karasevm.privatednstoggle.model.DnsServer + + +class ServerListRecyclerAdapter(private val showDragHandle: Boolean) : + RecyclerView.Adapter() { + + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): DnsServerViewHolder { + val view = + LayoutInflater.from(parent.context).inflate(R.layout.recyclerview_row, parent, false) + val vh = DnsServerViewHolder(view) + return vh + } + + override fun getItemCount(): Int { + return items.size + } + + var onItemClick: ((Int) -> Unit)? = null + var onDragStart: ((DnsServerViewHolder) -> Unit)? = null + private var items: MutableList = mutableListOf() + + @SuppressLint("ClickableViewAccessibility") + override fun onBindViewHolder(holder: DnsServerViewHolder, position: Int) { + val item = items[position] + if (item.label.isNotEmpty()) { + holder.labelTextView.text = item.label + holder.labelTextView.visibility = View.VISIBLE + } else { + holder.labelTextView.visibility = View.GONE + } + holder.serverTextView.text = item.server + holder.id = item.id + if (item.enabled) { + holder.labelTextView.alpha = 1f + holder.serverTextView.alpha = 1f + } else { + holder.labelTextView.alpha = 0.5f + holder.serverTextView.alpha = 0.5f + } + if (showDragHandle) { + holder.dragHandle.visibility = View.VISIBLE + holder.dragHandle.setOnTouchListener { _, event -> + if (event.actionMasked == MotionEvent.ACTION_DOWN) { + onDragStart?.invoke(holder) + } + return@setOnTouchListener true + } + } + } + + /** + * Update server position in memory + * @param fromPosition old position + * @param toPosition new position + */ + fun onItemMove(fromPosition: Int, toPosition: Int) { + items.add(toPosition, items.removeAt(fromPosition)) + notifyItemMoved(fromPosition, toPosition) + } + + class DiffCallback( + private val oldList: List, private var newList: List + ) : DiffUtil.Callback() { + + override fun getOldListSize(): Int { + return oldList.size + } + + override fun getNewListSize(): Int { + return newList.size + } + + override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean { + val oldItem = oldList[oldItemPosition] + val newItem = newList[newItemPosition] + return oldItem.id == newItem.id + } + + override fun areContentsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean { + val oldItem = oldList[oldItemPosition] + val newItem = newList[newItemPosition] + return oldItem.server == newItem.server && oldItem.label == newItem.label && oldItem.enabled == newItem.enabled + } + } + + /** + * Submit list to adapter + * @param list list to submit + */ + fun submitList(list: List) { + val diffCallback = DiffCallback(items, list) + val diffResult = DiffUtil.calculateDiff(diffCallback) + items.clear() + items.addAll(list) + diffResult.dispatchUpdatesTo(this) + } + + inner class DnsServerViewHolder(view: View) : RecyclerView.ViewHolder(view) { + val labelTextView: TextView = view.findViewById(R.id.labelTextView) + val serverTextView: TextView = view.findViewById(R.id.textView) + val dragHandle: ImageView = itemView.findViewById(R.id.dragHandle) + var id = 0 + + init { + view.setOnClickListener { + onItemClick?.invoke(id) + } + } + } + +} diff --git a/app/src/main/java/ru/karasevm/privatednstoggle/ui/SettingsDialogActivity.kt b/app/src/main/java/ru/karasevm/privatednstoggle/ui/SettingsDialogActivity.kt new file mode 100644 index 0000000..4b15e05 --- /dev/null +++ b/app/src/main/java/ru/karasevm/privatednstoggle/ui/SettingsDialogActivity.kt @@ -0,0 +1,12 @@ +package ru.karasevm.privatednstoggle.ui + +import android.os.Bundle +import androidx.appcompat.app.AppCompatActivity + +class SettingsDialogActivity : AppCompatActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + val newFragment = DNSServerDialogFragment() + newFragment.show(supportFragmentManager, DNSServerDialogFragment.TAG) + } +} \ No newline at end of file diff --git a/app/src/main/java/ru/karasevm/privatednstoggle/util/BackupUtils.kt b/app/src/main/java/ru/karasevm/privatednstoggle/util/BackupUtils.kt new file mode 100644 index 0000000..304f1db --- /dev/null +++ b/app/src/main/java/ru/karasevm/privatednstoggle/util/BackupUtils.kt @@ -0,0 +1,79 @@ +package ru.karasevm.privatednstoggle.util + +import android.content.SharedPreferences +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import ru.karasevm.privatednstoggle.data.DnsServerViewModel +import ru.karasevm.privatednstoggle.model.DnsServer +import ru.karasevm.privatednstoggle.util.PreferenceHelper.autoMode +import ru.karasevm.privatednstoggle.util.PreferenceHelper.requireUnlock + +object BackupUtils { + @Serializable + data class Backup( + @SerialName("dns_servers") val dnsServers: List, + @SerialName("auto_mode") val autoMode: Int?, + @SerialName("require_unlock") val requireUnlock: Boolean?, + ) + + @Serializable + data class LegacyBackup( + @SerialName("dns_servers") val dnsServers: String, + @SerialName("auto_mode") val autoMode: Int?, + @SerialName("require_unlock") val requireUnlock: Boolean?, + ) + + /** + * Exports all the preferences + * @param viewModel View model + * @param sharedPreferences Shared preferences + */ + fun export(viewModel: DnsServerViewModel, sharedPreferences: SharedPreferences): Backup { + return Backup( + viewModel.allServers.value ?: listOf(), + sharedPreferences.autoMode, + sharedPreferences.requireUnlock + ) + } + + /** + * Imports all the preferences + * @param backup Deserialized backup + * @param viewModel View model + */ + fun import( + backup: Backup, + viewModel: DnsServerViewModel, + sharedPreferences: SharedPreferences + ) { + backup.dnsServers.forEach { viewModel.insert(it) } + sharedPreferences.autoMode = backup.autoMode ?: sharedPreferences.autoMode + sharedPreferences.requireUnlock = backup.requireUnlock ?: sharedPreferences.requireUnlock + } + + /** + * Imports old server list + * @param legacyBackup Deserialized backup + * @param viewModel View model + * @param sharedPreferences Shared preferences + */ + fun importLegacy( + legacyBackup: LegacyBackup, + viewModel: DnsServerViewModel, + sharedPreferences: SharedPreferences + ) { + legacyBackup.dnsServers.let { servers -> + val serverList = servers.split(",") + serverList.forEach { server -> + val parts = server.split(" : ") + if (parts.size == 2) { + viewModel.insert(DnsServer(0, parts[1], parts[0])) + } else { + viewModel.insert(DnsServer(0, server, "")) + } + } + } + sharedPreferences.autoMode = legacyBackup.autoMode?: 0 + sharedPreferences.requireUnlock = legacyBackup.requireUnlock == true + } +} \ No newline at end of file diff --git a/app/src/main/java/ru/karasevm/privatednstoggle/util/PrivateDNSUtils.kt b/app/src/main/java/ru/karasevm/privatednstoggle/util/PrivateDNSUtils.kt new file mode 100644 index 0000000..9441421 --- /dev/null +++ b/app/src/main/java/ru/karasevm/privatednstoggle/util/PrivateDNSUtils.kt @@ -0,0 +1,52 @@ +package ru.karasevm.privatednstoggle.util + +import android.Manifest +import android.content.ContentResolver +import android.content.Context +import android.content.pm.PackageManager +import android.provider.Settings +import androidx.core.content.ContextCompat.checkSelfPermission + +@Suppress("unused") +object PrivateDNSUtils { + const val DNS_MODE_OFF = "off" + const val DNS_MODE_AUTO = "opportunistic" + const val DNS_MODE_PRIVATE = "hostname" + + const val AUTO_MODE_OPTION_OFF = 0 + const val AUTO_MODE_OPTION_AUTO = 1 + const val AUTO_MODE_OPTION_OFF_AUTO = 2 + const val AUTO_MODE_OPTION_PRIVATE = 3 + + private const val PRIVATE_DNS_MODE = "private_dns_mode" + private const val PRIVATE_DNS_PROVIDER = "private_dns_specifier" + + + // Gets the system dns mode + fun getPrivateMode(contentResolver: ContentResolver): String { + return Settings.Global.getString(contentResolver, PRIVATE_DNS_MODE) + } + + // Gets the system dns provider + fun getPrivateProvider(contentResolver: ContentResolver): String { + return Settings.Global.getString(contentResolver, PRIVATE_DNS_PROVIDER) + } + + // Sets the system dns mode + fun setPrivateMode(contentResolver: ContentResolver, value: String) { + Settings.Global.putString(contentResolver, PRIVATE_DNS_MODE, value) + } + + // Sets the system dns provider + fun setPrivateProvider(contentResolver: ContentResolver, value: String?) { + Settings.Global.putString(contentResolver, PRIVATE_DNS_PROVIDER, value) + } + + fun checkForPermission(context: Context): Boolean { + return checkSelfPermission( + context, + Manifest.permission.WRITE_SECURE_SETTINGS + ) == PackageManager.PERMISSION_GRANTED + } + +} \ No newline at end of file diff --git a/app/src/main/java/ru/karasevm/privatednstoggle/util/SharedPrefUtils.kt b/app/src/main/java/ru/karasevm/privatednstoggle/util/SharedPrefUtils.kt new file mode 100644 index 0000000..129a56b --- /dev/null +++ b/app/src/main/java/ru/karasevm/privatednstoggle/util/SharedPrefUtils.kt @@ -0,0 +1,57 @@ +package ru.karasevm.privatednstoggle.util + +import android.content.Context +import android.content.SharedPreferences + +object PreferenceHelper { + + const val DNS_SERVERS = "dns_servers" + const val AUTO_MODE = "auto_mode" + const val REQUIRE_UNLOCK = "require_unlock" + + fun defaultPreference(context: Context): SharedPreferences = + context.getSharedPreferences("app_prefs", 0) + + private inline fun SharedPreferences.editMe(operation: (SharedPreferences.Editor) -> Unit) { + val editMe = edit() + operation(editMe) + editMe.apply() + } + + private fun SharedPreferences.Editor.put(pair: Pair) { + val key = pair.first + when (val value = pair.second) { + is String -> putString(key, value) + is Int -> putInt(key, value) + is Boolean -> putBoolean(key, value) + is Long -> putLong(key, value) + is Float -> putFloat(key, value) + else -> error("Only primitive types can be stored in SharedPreferences, got ${value.javaClass}") + } + } + + var SharedPreferences.dns_servers + get() = getString(DNS_SERVERS, "")!!.split(",").toMutableList() + set(items) { + editMe { + it.put(DNS_SERVERS to items.joinToString(separator = ",")) + } + } + + + var SharedPreferences.autoMode + get() = getInt(AUTO_MODE, PrivateDNSUtils.AUTO_MODE_OPTION_OFF) + set(value) { + editMe { + it.put(AUTO_MODE to value) + } + } + + var SharedPreferences.requireUnlock + get() = getBoolean(REQUIRE_UNLOCK, false) + set(value) { + editMe { + it.put(REQUIRE_UNLOCK to value) + } + } +} diff --git a/app/src/main/java/ru/karasevm/privatednstoggle/util/ShizukuUtil.kt b/app/src/main/java/ru/karasevm/privatednstoggle/util/ShizukuUtil.kt new file mode 100644 index 0000000..fe9b5cb --- /dev/null +++ b/app/src/main/java/ru/karasevm/privatednstoggle/util/ShizukuUtil.kt @@ -0,0 +1,82 @@ +package ru.karasevm.privatednstoggle.util + +import android.Manifest +import android.content.Context +import android.content.pm.IPackageManager +import android.os.Build +import android.os.Process +import android.os.UserHandle +import android.permission.IPermissionManager +import android.util.Log +import org.lsposed.hiddenapibypass.HiddenApiBypass +import rikka.shizuku.ShizukuBinderWrapper +import rikka.shizuku.SystemServiceHelper +import ru.karasevm.privatednstoggle.util.PrivateDNSUtils.checkForPermission + +object ShizukuUtil { + + private const val TAG = "ShizukuUtil" + + /** + * Attempts to grant the WRITE_SECURE_SETTINGS permission using Shizuku. + * + * @param context The context from which the method is called. + * @return True if the permission was granted successfully, false otherwise. + */ + fun grantPermissionWithShizuku(context: Context): Boolean { + val packageName = context.packageName + var userId = 0 + runCatching { + val userHandle = Process.myUserHandle() + userId = UserHandle::class.java.getMethod("getIdentifier").invoke(userHandle) as? Int ?: 0 + } + if (Build.VERSION.SDK_INT >= 31) { + HiddenApiBypass.addHiddenApiExemptions("Landroid/permission") + val binder = + ShizukuBinderWrapper(SystemServiceHelper.getSystemService("permissionmgr")) + val pm = IPermissionManager.Stub.asInterface(binder) + runCatching { + pm.grantRuntimePermission( + packageName, + Manifest.permission.WRITE_SECURE_SETTINGS, + userId + ) + }.onFailure { e -> + Log.w(TAG, "Android 12 method failed: ", e) + runCatching { + pm.grantRuntimePermission( + packageName, + Manifest.permission.WRITE_SECURE_SETTINGS, + 0, + userId + ) + }.onFailure { e -> + Log.w(TAG, "Android 14 QPR2 method failed: ", e) + runCatching { + pm.grantRuntimePermission( + packageName, + Manifest.permission.WRITE_SECURE_SETTINGS, + "default:0", + userId + ) + }.onFailure { e -> + Log.w(TAG, "Android 14 QPR3 method failed: ", e) + } + } + } + } else { + val binder = ShizukuBinderWrapper(SystemServiceHelper.getSystemService("package")) + val pm = IPackageManager.Stub.asInterface(binder) + runCatching { + pm.grantRuntimePermission( + packageName, + Manifest.permission.WRITE_SECURE_SETTINGS, + userId + ) + }.onFailure { e -> + Log.w(TAG, "Android <12 method failed: ", e) + } + } + return checkForPermission(context) + } +} \ No newline at end of file diff --git a/app/src/main/res/drawable-anydpi/ic_tile_default.xml b/app/src/main/res/drawable-anydpi/ic_tile_default.xml deleted file mode 100644 index 2af57be..0000000 --- a/app/src/main/res/drawable-anydpi/ic_tile_default.xml +++ /dev/null @@ -1,15 +0,0 @@ - - - - - diff --git a/app/src/main/res/drawable-hdpi/ic_tile_default.png b/app/src/main/res/drawable-hdpi/ic_tile_default.png deleted file mode 100644 index 9809f36..0000000 Binary files a/app/src/main/res/drawable-hdpi/ic_tile_default.png and /dev/null differ diff --git a/app/src/main/res/drawable-mdpi/ic_tile_default.png b/app/src/main/res/drawable-mdpi/ic_tile_default.png deleted file mode 100644 index cb09290..0000000 Binary files a/app/src/main/res/drawable-mdpi/ic_tile_default.png and /dev/null differ diff --git a/app/src/main/res/drawable-xhdpi/ic_tile_default.png b/app/src/main/res/drawable-xhdpi/ic_tile_default.png deleted file mode 100644 index 5564007..0000000 Binary files a/app/src/main/res/drawable-xhdpi/ic_tile_default.png and /dev/null differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_tile_default.png b/app/src/main/res/drawable-xxhdpi/ic_tile_default.png deleted file mode 100644 index 35f5c5f..0000000 Binary files a/app/src/main/res/drawable-xxhdpi/ic_tile_default.png and /dev/null differ diff --git a/app/src/main/res/drawable/ic_baseline_add_24.xml b/app/src/main/res/drawable/ic_baseline_add_24.xml index 70046c4..a904f51 100644 --- a/app/src/main/res/drawable/ic_baseline_add_24.xml +++ b/app/src/main/res/drawable/ic_baseline_add_24.xml @@ -1,4 +1,5 @@ - diff --git a/app/src/main/res/drawable/ic_baseline_settings_24.xml b/app/src/main/res/drawable/ic_baseline_settings_24.xml new file mode 100644 index 0000000..e7e3d4f --- /dev/null +++ b/app/src/main/res/drawable/ic_baseline_settings_24.xml @@ -0,0 +1,10 @@ + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_drag_handle_24.xml b/app/src/main/res/drawable/ic_drag_handle_24.xml new file mode 100644 index 0000000..892b734 --- /dev/null +++ b/app/src/main/res/drawable/ic_drag_handle_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml index 0ff5544..88e1536 100644 --- a/app/src/main/res/layout/activity_main.xml +++ b/app/src/main/res/layout/activity_main.xml @@ -4,12 +4,65 @@ xmlns:tools="http://schemas.android.com/tools" android:layout_width="match_parent" android:layout_height="match_parent" - tools:context=".MainActivity"> + tools:context=".ui.MainActivity" + android:fitsSystemWindows="true"> + + + + + + + android:layout_height="wrap_content" + app:layout_constraintTop_toBottomOf="@+id/topAppBarLayout" /> + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/dialog_add.xml b/app/src/main/res/layout/dialog_add.xml index 1ec3b51..fe08fe9 100644 --- a/app/src/main/res/layout/dialog_add.xml +++ b/app/src/main/res/layout/dialog_add.xml @@ -4,18 +4,60 @@ android:layout_width="match_parent" android:layout_height="wrap_content"> - + + + + + + + + + app:layout_constraintTop_toBottomOf="@+id/editTextServerHintLayout"> + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/dialog_options.xml b/app/src/main/res/layout/dialog_options.xml new file mode 100644 index 0000000..11f6ce1 --- /dev/null +++ b/app/src/main/res/layout/dialog_options.xml @@ -0,0 +1,65 @@ + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/recyclerview_row.xml b/app/src/main/res/layout/recyclerview_row.xml index fa7bb16..4da379a 100644 --- a/app/src/main/res/layout/recyclerview_row.xml +++ b/app/src/main/res/layout/recyclerview_row.xml @@ -1,22 +1,45 @@ + android:focusable="true" + android:foreground="?android:attr/selectableItemBackground"> + app:layout_constraintTop_toTopOf="parent" /> + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/sheet_dns_selector.xml b/app/src/main/res/layout/sheet_dns_selector.xml new file mode 100644 index 0000000..bbe5b42 --- /dev/null +++ b/app/src/main/res/layout/sheet_dns_selector.xml @@ -0,0 +1,22 @@ + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/menu/menu_main.xml b/app/src/main/res/menu/menu_main.xml index 58b0127..fcfec62 100644 --- a/app/src/main/res/menu/menu_main.xml +++ b/app/src/main/res/menu/menu_main.xml @@ -1,15 +1,39 @@ - + xmlns:android="http://schemas.android.com/apk/res/android"> + android:id="@+id/options" + android:title="@string/options" + app:showAsAction="ifRoom" + android:icon="@drawable/ic_baseline_settings_24" + /> + + + + + + + + + + + + + diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher.png b/app/src/main/res/mipmap-hdpi/ic_launcher.png deleted file mode 100644 index 05b82a8..0000000 Binary files a/app/src/main/res/mipmap-hdpi/ic_launcher.png and /dev/null differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher.png b/app/src/main/res/mipmap-mdpi/ic_launcher.png deleted file mode 100644 index b0a456c..0000000 Binary files a/app/src/main/res/mipmap-mdpi/ic_launcher.png and /dev/null differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/app/src/main/res/mipmap-xhdpi/ic_launcher.png deleted file mode 100644 index 4f1fa21..0000000 Binary files a/app/src/main/res/mipmap-xhdpi/ic_launcher.png and /dev/null differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/app/src/main/res/mipmap-xxhdpi/ic_launcher.png deleted file mode 100644 index 89f96ba..0000000 Binary files a/app/src/main/res/mipmap-xxhdpi/ic_launcher.png and /dev/null differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png deleted file mode 100644 index 1cd6644..0000000 Binary files a/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png and /dev/null differ diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/app/src/main/res/mipmap/ic_launcher.xml similarity index 91% rename from app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml rename to app/src/main/res/mipmap/ic_launcher.xml index 7005cb8..5c84730 100644 --- a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml +++ b/app/src/main/res/mipmap/ic_launcher.xml @@ -1,6 +1,6 @@ - - + + - \ No newline at end of file + diff --git a/app/src/main/res/resources.properties b/app/src/main/res/resources.properties new file mode 100644 index 0000000..d5a3ddc --- /dev/null +++ b/app/src/main/res/resources.properties @@ -0,0 +1 @@ +unqualifiedResLocale=en-US \ No newline at end of file diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml new file mode 100644 index 0000000..048dcdf --- /dev/null +++ b/app/src/main/res/values-fr/strings.xml @@ -0,0 +1,54 @@ + + + Terminé + Private DNS Quick Toggle + Inconnu + Éteint + Ajouter un serveur + Ajouter + Politique de confidentialité + Êtes-vous sûr de vouloir supprimer le serveur ? + Supprimer + L\'adresse du serveur ne peut pas être vide + Étiquette du serveur DNS (Facultatif) + Adresse du serveur DNS + Options + OK + Seulement éteint + Seulement automatique + Éteint et automatique + Automatique + Commutateur de DNS privé + Enregistrer + Autorisation non accordée, vérifiez les instructions dans l\'application + Sélectionner le serveur + Définissez les options à inclure + DNS privé seulement + Ouvrir l\'application + DNS privé éteint + DNS privé réglé sur automatique + DNS privé réglé sur %1$s + Requiert le déverrouillage de l\'appareil pour changer de serveur + Activé + À partir du fichier + Aucun serveur ajouté + Copié + Éditer le serveur + Échec de la sauvegarde + Appuyez sur le bouton ci-dessous pour en ajouter un + Impossible d\'obtenir l\'autorisation, veuillez l\'accorder manuellement + Autorisation accordée, vous pouvez désormais révoquer l\'autorisation Shizuku + À partir du presse-papier + Vers le presse-papier + Partager + Vers le fichier + Sauvegarde réussie + Échec de l\'importation + Échec de l\'importation, le fichier JSON est incorrect + Importé + Exporter + Importer + Poignée + Supprimer + Annuler + diff --git a/app/src/main/res/values-hu/strings.xml b/app/src/main/res/values-hu/strings.xml new file mode 100644 index 0000000..3e9260d --- /dev/null +++ b/app/src/main/res/values-hu/strings.xml @@ -0,0 +1,51 @@ + + Privát DNS Gyorskapcsoló + Privát DNS Kapcsoló + Nincs engedély megadva, nézd meg az alkalmazásban, hogyan adhatod meg + Ki + Automatikus + Ismeretlen + Szerver hozzáadása + Hozzáadás + Mentés + Adatvédelmi irányelvek + Szerver kiválasztása + Kész + Mégse + Törlés + Biztosan törölni szeretnéd a szervert? + Törlés + A szervercím nem lehet üres + DNS szerver neve (opcionális) + DNS szerver címe + Beállítások + OK + Válaszd ki, mely opciók jelenjenek meg a csempén + Csak ki + Csak automatikus + Ki és automatikus + Csak Privát DNS + Alkalmazás megnyitása + Privát DNS kikapcsolva + Privát DNS automatikus módra állítva + Privát DNS beállítva: %1$s + Eszköz feloldása szükséges a szerver módosításához + Húzási fogantyú + Importálás + Exportálás + Importálva + Importálás sikertelen + Importálás sikertelen, hibás JSON + Másolva + Fájlból + Vágólapról + Vágólapra + Megosztás + Fájlba + Mentés sikertelen + Sikeresen mentve + Szerver szerkesztése + Nincsenek szerverek hozzáadva + Koppints az alábbi gombra, hogy hozzáadj egyet + Engedélyezve + diff --git a/app/src/main/res/values-mn/strings.xml b/app/src/main/res/values-mn/strings.xml new file mode 100644 index 0000000..10de470 --- /dev/null +++ b/app/src/main/res/values-mn/strings.xml @@ -0,0 +1,49 @@ + + + Хувийн DNS солих + Унтраах + Тодорхойгүй + Сервер нэмэх + Хадгалах + Болсон + Болих + Устгах + Та серверийг устгахдаа итгэлтэй байна уу? + Устгах + Серверийн хаяг хоосон байж болохгүй + DNS серверийн шошго (заавал биш) + DNS серверийн хаяг + Сонголтууд + ОК + Хавтан дээр ямар сонголтыг оруулахаа сонгоно уу + Зөвхөн унтарсан + Зөвхөн авто + Унтарсан болон авто + Хувийн DNS-г %1$s болгож тохируулсан + Серверийг өөрчлөхийн тулд төхөөрөмжийн түгжээг тайлах шаардлагатай + Бариулыг чирэх + Импорт + Импортолсон + Импорт хийж чадсангүй + Файлаас + Хадгалж чадсангүй + Амжилттай хадгалсан + Сервер засах + Хувийн DNS хурдан сэлгэх + Нууцлалын бодлого + Зөвшөөрөл олгоогүй. Үүнийг хэрхэн хийхийг харна уу + Авто + Нэмэх + Сервер сонгох + Зөвхөн хувийн DNS + Апп нээх + Түр санах ой руу + Хувийн DNS унтарсан + Хувийн DNS-г автоматаар тохируулсан + Экспорт + Хуулагдсан + Хуваалцах + Импорт хийж чадсангүй, алдаатай JSON + Түр санах ойноос + Файлруу + \ No newline at end of file diff --git a/app/src/main/res/values-night/themes.xml b/app/src/main/res/values-night/themes.xml index aa13073..715cab4 100644 --- a/app/src/main/res/values-night/themes.xml +++ b/app/src/main/res/values-night/themes.xml @@ -1,16 +1,12 @@ - - \ No newline at end of file diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml new file mode 100644 index 0000000..6c76614 --- /dev/null +++ b/app/src/main/res/values-pl/strings.xml @@ -0,0 +1,54 @@ + + + Usuń + Zapisz + Polityka prywatności + Wybierz serwer + Anuluj + Adres serwera DNS + OK + Otwórz aplikację + Importuj + Eksportuj + Skopiowano + Z pliku + Ze schowka + Do schowka + Udostępnij + Do pliku + Edytuj serwer + Brak dodanych serwerów + Włączone + Dodaj serwer + Dodaj + Usuń + Nieznane + Gotowe + Opcje + Importowanie nie powiodło się + Automatycznie + Wyłącz + Zaimportowano + Adres serwera nie może być pusty + Import nie powiódł się, zniekształcony plik JSON + Zapisano pomyślnie + Czy na pewno chcesz usunąć serwer? + Private DNS Quick Toggle + Przełącznik prywatnego DNS + Nieprzydzielono uprawnienia, sprawdź w aplikacji, w jaki sposób można to zrobić + Opis serwera DNS (opcjonalnie) + Wybierz opcje, które będą dostępne w kafelku + Tylko wyłączenie + Tylko automatycznie + Wyłączenie i automatycznie + Tylko prywatny DNS + Prywatny DNS zmieniony na automatyczny + Prywatny DNS zmieniony na %1$s + Wymagaj odblokowania urządzenia do zmiany serwera + Wyłączono Prywatny DNS + Przeciągnij + Zapisywanie nie powiodło się + Kliknij na poniższy przycisk, aby dodać nowy + Udzielono zezwolenia, możesz teraz cofnąć zezwolenie w Shizuku + Uzyskanie uprawnień nie powiodło się, udziel ich ręcznie + diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml new file mode 100644 index 0000000..c4691bd --- /dev/null +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -0,0 +1,54 @@ + + + Importado + Permissão não concedida, verifique o app para saber como prosseguir + Alteração de DNS privado + Cancelar + Adicionar servidor + Endereço do servidor DNS + Escolha opções disponível em atalho + Alteração de DNS privado + Desativado + Automático + Indeterminado + Adicionar + Salvar + Política de privacidade + Concluído + Apagar + Tem certeza de que quer apagar o servidor? + Apagar + O endereço do servidor não pode estar em branco + Identificação do servidor DNS (opcional) + Opções + Ok + Somente desativado + Desativado e automático + Somente DNS privado + Abrir app + DNS privado desativado + DNS privado definido para automático + DNS privado definido para %1$s + Arrastre + Importar + Exportar + Falha na importação, JSON malformado + Copiado + Da memória + Compartilhar + Para arquivo + Salvo com sucesso + Editar servidor + Nenhum servidor adicionado + Toque no botão abaixo para adicionar + Ativado + Escolha servidor + Para memória + De arquivo + Falha ao importar + Falha ao salvar + Necessário desbloquear o dispositivo para alterar servidor + Somente automático + Falha ao obter a permissão. Tente conceder manualmente + Permissão concedida, você pode revogar a permissão do Shizuku agora + diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml new file mode 100644 index 0000000..cef59e3 --- /dev/null +++ b/app/src/main/res/values-ru/strings.xml @@ -0,0 +1,53 @@ + + Private DNS Quick Toggle + Переключить частный DNS + Разрешение не предоставлено, проверьте приложение для получения информации + Выкл + Авто + Неизвестно + Добавить сервер + Добавить + Сохранить + Политика конфиденциальности + Выбрать сервер + Готово + Отмена + Удалить + Вы уверены, что хотите удалить сервер? + Удалить + Адрес сервера не может быть пустым + Название DNS сервера (необязательно) + Адрес DNS сервера + Опции + OK + Выберите, какие опции включить в плитке + Только \"Выкл\" + Только \"Авто\" + \"Выкл\" и \"Авто\" + Только частный DNS + Открыть приложение + Частный DNS выключен + Частный DNS установлен на "Авто" + Частный DNS установлен на %1$s + Смена сервера требует разблокировки устройства + Ручка перетаскивания + Импорт + Экспорт + Успешно импортировано + Импорт не удался + Импорт не удался, некорректный JSON + Скопировано + Из файла + Из буфера обмена + В буфер обмена + Поделиться + В файл + Сохранение не удалось + Успешно сохранено + Редактировать сервер + Нет доступных серверов + Нажмите на кнопку ниже, чтобы добавить сервер + Включён + Разрешение получено, можно отозвать авторизацию Shizuku + Не удалось получить разрешение, предоставьте его вручную + \ No newline at end of file diff --git a/app/src/main/res/values-ta/strings.xml b/app/src/main/res/values-ta/strings.xml new file mode 100644 index 0000000..1cb02d2 --- /dev/null +++ b/app/src/main/res/values-ta/strings.xml @@ -0,0 +1,54 @@ + + + சரி + தனியார் டி.என்.எச் விரைவாக மாற்று + தனியார் டி.என் கள் மாறுகின்றன + இசைவு வழங்கப்படவில்லை, அதை எப்படி செய்வது என்று பார்க்க பயன்பாட்டை சரிபார்க்கவும் + அணை + தானி + தெரியவில்லை + சேவையகத்தைச் சேர்க்கவும் + கூட்டு + சேமி + தனியுரிமைக் கொள்கை + சேவையகத்தைத் தேர்ந்தெடுக்கவும் + முடிந்தது + ரத்துசெய் + நீக்கு + சேவையகத்தை நீக்க விரும்புகிறீர்களா? + நீக்கு + டிஎன்எச் சேவையக முகவரி + விருப்பங்கள் + ஓடுகளில் எந்த விருப்பங்களைச் சேர்க்க வேண்டும் என்பதைத் தேர்வுசெய்க + மட்டுமே + ஆட்டோ மட்டுமே + ஆஃப் மற்றும் ஆட்டோ + தனியார் டி.என்.எச் மட்டுமே + திறந்த பயன்பாடு + தனியார் டி.என்.எச் அணைக்கப்பட்டது + தனியார் டி.என்.எச் ஆட்டோவாக அமைக்கப்பட்டுள்ளது + தனியார் டி.என்.எச் %1$s என அமைக்கப்பட்டுள்ளது + சேவையகத்தை மாற்ற சாதனத்தைத் திறக்க வேண்டும் + இழுவை கைப்பிடி + இறக்குமதி + ஏற்றுமதி + இறக்குமதி செய்யப்பட்டது + இறக்குமதி தோல்வியடைந்தது + இறக்குமதி தோல்வியுற்றது, தவறாக சாதொபொகு + நகலெடுக்கப்பட்டது + கோப்பிலிருந்து + கிளிப்போர்டிலிருந்து + இடைநிலைப்பலகைக்கு + பங்கு + தாக்கல் செய்ய + சேமிப்பு தோல்வியடைந்தது + வெற்றிகரமாக சேமிக்கப்பட்டது + சேவையகத்தைத் திருத்து + சேவையகங்கள் எதுவும் சேர்க்கப்படவில்லை + ஒன்றைச் சேர்க்க கீழே உள்ள பொத்தானைத் தட்டவும் + இயக்கப்பட்டது + இசைவு வழங்கப்பட்டது, நீங்கள் இப்போது சிசுகு அனுமதியை ரத்து செய்யலாம் + இசைவு பெறுவதில் தோல்வி, தயவுசெய்து அதை கைமுறையாக வழங்கவும் + சேவையக முகவரி காலியாக இருக்க முடியாது + டிஎன்எச் சேவையக சிட்டை (விரும்பினால்) + diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml new file mode 100644 index 0000000..57f2c8d --- /dev/null +++ b/app/src/main/res/values-tr/strings.xml @@ -0,0 +1,54 @@ + + + Sunucuyu silmek istediğinizden emin misiniz? + Sadece kapalı + Sunucuyu değiştirmek için cihazın kilidini açmanız gerekiyor + Oto + Bilinmeyen + Tamam + İptal + DNS sunucusu Etiketi (İsteğe bağlı) + Seçenekler + OK + Sadece oto + Kapalı ve oto + Özel DNS kapandı + Uygulamayı açınız + İçe aktar + Dışa aktar + içe aktarıldı + Dosyaya + Kaydetme başarısız oldu + Hiç Sunucu Eklenmedi + Eklemek için aşağıdaki düğmeye dokunun + İzin alınamadı, lütfen manuel olarak verin + Karoya hangi seçeneklerin dahil edileceğini seçiniz + Kapalı + Gizlilik Politikası + Sil + Sunucu adresi boş olamaz + Sil + DNS sunucu adresi + tutacağı sürükle + Ekle + Sunucu Ekleyiniz + İzin verilmedi, nasıl yapıldığını görmek için uygulamayı kontrol ediniz + İçe aktarma başarısız oldu, hatalı biçimlendirilmiş JSON + Özel DNS Hızlı Geçiş + İzin verildi, şimdi Shizuku iznini iptal edebilirsiniz + Aktarma başarısız + Panodan + Başarıyla kaydedildi + Kaydet + Özel DNS otomatik olarak ayarlandı + Özel DNS %1$s olarak ayarlandı + Sadece Özel DNS + Sunucuyu Seçin + Kopyalandı + Sunucuyu düzenle + Etkin + Özel DNS Geçişi + Dosyadan + Panoya + Paylaş + diff --git a/app/src/main/res/values-vi/strings.xml b/app/src/main/res/values-vi/strings.xml new file mode 100644 index 0000000..1f423ae --- /dev/null +++ b/app/src/main/res/values-vi/strings.xml @@ -0,0 +1,52 @@ + + + Thêm máy chủ + Chỉ tự động + Chưa được cấp quyền, hãy kiểm tra ứng dụng để biết cách thực hiện + Tắt + Tự động + Thêm + Chính sách bảo mật + Chọn máy chủ + Hoàn thành + Hủy + Bạn có chắc chắn muốn xóa máy chủ không? + Xoá + Địa chỉ máy chủ DNS + Tùy chọn + OK + Chọn các tùy chọn để đưa vào ô + Chỉ tắt + Tắt và tự động + Mở ứng dụng + DNS cá nhân được thiết lập tự động + Yêu cầu mở khóa thiết bị để thay đổi máy chủ + Tay cầm kéo + Xuất + Đã nhập + Nhập thất bại + Nhập thất bại, JSON bị lỗi + Đã sao chép + Từ tập tin + Chia sẻ + Thành tập tin + Lưu không thành công + Chỉnh sửa máy chủ + Chưa có máy chủ nào + Nhấn vào nút bên dưới để thêm + Đã bật + Chuyển đổi DNS cá nhân + Xoá + Chuyển đổi nhanh DNS cá nhân + Không rõ + Nhãn máy chủ DNS (Không bắt buộc) + Lưu + Địa chỉ máy chủ không được để trống + Chỉ DNS cá nhân + Đã tắt DNS cá nhân + DNS cá nhân được đặt thành %1$s + Nhập + Từ bảng nhớ tạm + Vào bảng nhớ tạm + Đã lưu thành công + \ No newline at end of file diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml new file mode 100644 index 0000000..a217315 --- /dev/null +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -0,0 +1,51 @@ + + + 私有DNS触发 + 必要权限未授予,请看相关说明 + 关闭 + 自动 + 未知 + 添加DNS服务器 + 添加 + 存储 + 隐私策略 + 选择服务器 + 完成 + 取消 + 删除条目 + 你确认要删除这个服务器条目吗? + 删除 + 服务器地址不可为空 + DNS服务器标识 + DNS服务器地址 + 选项 + 确认 + 选择要在磁贴中启用的选项 + 仅“关闭” + 仅“自动” + “关闭“与”自动“ + 仅设置的私有DNS + 打开软件 + 不使用私有DNS + 自动使用私有DNS + 设置为使用私有DNS\"%1$s\" + 更改服务器设置要求设备解锁 + 拖动把手 + 导入 + 导出 + 已导入 + 导入失败 + 导入失败,json格式异常 + 已复制 + 从文件导入 + 从剪贴板导入 + 导出至剪贴板 + 分享 + 导出至文件 + 保存失败 + 保存成功 + 编辑服务器条目 + 无可用服务器 + 点击下方\"+\"添加一个吧 + 已启用 + \ No newline at end of file diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml index f8c6127..3643757 100644 --- a/app/src/main/res/values/colors.xml +++ b/app/src/main/res/values/colors.xml @@ -1,10 +1,15 @@ - - - #FFBB86FC - #FF6200EE - #FF3700B3 - #FF03DAC5 - #FF018786 - #FF000000 - #FFFFFFFF - \ No newline at end of file + + + #498EE8 + + #6750A4 + #6750A4 + #625B71 + #7D5260 + #FFFBFE + + #D0BCFF + #CCC2DC + #EFB8C8 + #1C1B1F + diff --git a/app/src/main/res/values/ic_launcher_background.xml b/app/src/main/res/values/ic_launcher_background.xml deleted file mode 100644 index cf31181..0000000 --- a/app/src/main/res/values/ic_launcher_background.xml +++ /dev/null @@ -1,4 +0,0 @@ - - - #498EE8 - \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 1765f1e..8124a26 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -7,12 +7,47 @@ Unknown Add Server Add + Save Privacy Policy - Enable auto - Automatic (opportunistic) DNS mode will now be available in the tile + Select Server + Done Cancel - Delete server? + Delete + Are you sure you want to delete server? Delete Server address cannot be empty + DNS server Label (Optional) DNS server address + Options + OK + Choose which options to include in the tile + Only off + Only auto + Off and auto + Only Private DNS + Open app + Private DNS turned off + Private DNS set to auto + Private DNS set to %1$s + Require unlocking the device to change server + Drag handle + Import + Export + Imported + Import failed + Import failed, malformed JSON + Copied + From file + From clipboard + To clipboard + Share + To file + Saving failed + Saved successfully + Edit server + No Servers Added + Tap on the button below to add one + Enabled + Permission granted, you can revoke the Shizuku permission now + Failed to acquire permission, please grant it manually \ No newline at end of file diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml index c2f6018..ea05d60 100644 --- a/app/src/main/res/values/themes.xml +++ b/app/src/main/res/values/themes.xml @@ -1,16 +1,27 @@ - - + + \ No newline at end of file diff --git a/build.gradle b/build.gradle deleted file mode 100644 index 07615a2..0000000 --- a/build.gradle +++ /dev/null @@ -1,18 +0,0 @@ -// Top-level build file where you can add configuration options common to all sub-projects/modules. -buildscript { - repositories { - google() - mavenCentral() - } - dependencies { - classpath 'com.android.tools.build:gradle:8.3.0' - classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:1.8.22" - - // NOTE: Do not place your application dependencies here; they belong - // in the individual module build.gradle files - } -} - -tasks.register('clean', Delete) { - delete rootProject.buildDir -} \ No newline at end of file diff --git a/build.gradle.kts b/build.gradle.kts new file mode 100644 index 0000000..45827dc --- /dev/null +++ b/build.gradle.kts @@ -0,0 +1,19 @@ +// Top-level build file where you can add configuration options common to all sub-projects/modules. +buildscript { + repositories { + google() + mavenCentral() + } + dependencies { + classpath("com.android.tools.build:gradle:8.8.1") + classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:2.0.20") + + // NOTE: Do not place your application dependencies here; they belong + // in the individual module build.gradle files + } +} +plugins { + id("org.jetbrains.kotlin.android") version "2.0.20" apply false + id("org.jetbrains.kotlin.plugin.serialization") version "2.0.20" + id("com.google.devtools.ksp") version "2.0.20-1.0.25" apply false +} diff --git a/fastlane/metadata/android/en-US/changelogs/11.txt b/fastlane/metadata/android/en-US/changelogs/11.txt new file mode 100644 index 0000000..e7b902d --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/11.txt @@ -0,0 +1,2 @@ +- Add option to require unlocking the device to use the tile +- Fix invisible nav buttons on some devices \ No newline at end of file diff --git a/fastlane/metadata/android/en-US/changelogs/12.txt b/fastlane/metadata/android/en-US/changelogs/12.txt new file mode 100644 index 0000000..46fe2d8 --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/12.txt @@ -0,0 +1,4 @@ +- Support for selection of only Private DNS in Option Dialog by @InfiniteCoder06 +- Support Labels by @InfiniteCoder06 +- Feature: Reordring by @InfiniteCoder06 +- Possible tile update fix \ No newline at end of file diff --git a/fastlane/metadata/android/en-US/changelogs/13.txt b/fastlane/metadata/android/en-US/changelogs/13.txt new file mode 100644 index 0000000..c546656 --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/13.txt @@ -0,0 +1,2 @@ +- Settings export/import +- Fix label not appearing in some cases \ No newline at end of file diff --git a/fastlane/metadata/android/en-US/changelogs/14.txt b/fastlane/metadata/android/en-US/changelogs/14.txt new file mode 100644 index 0000000..d8ece24 --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/14.txt @@ -0,0 +1,3 @@ +- Add an option to edit servers +- Add placeholder for empty server list +- Fix layout for longer server addresses diff --git a/fastlane/metadata/android/en-US/changelogs/15.txt b/fastlane/metadata/android/en-US/changelogs/15.txt new file mode 100644 index 0000000..8541dd6 --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/15.txt @@ -0,0 +1,2 @@ +- Fix crashes on Android 11 and earlier +- Fix list entry layout diff --git a/fastlane/metadata/android/en-US/changelogs/16.txt b/fastlane/metadata/android/en-US/changelogs/16.txt new file mode 100644 index 0000000..1005cb7 --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/16.txt @@ -0,0 +1,7 @@ +- Replaced server storage backend with Room, allowing for easier further expansion +- Add option to disable saved servers +- Improved backup handling +- Fixed desync bug while dragging servers +- Reorganized source file structure +- Updated Kotlin version +- Updated Java version diff --git a/fastlane/metadata/android/en-US/changelogs/17.txt b/fastlane/metadata/android/en-US/changelogs/17.txt new file mode 100644 index 0000000..3709a99 --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/17.txt @@ -0,0 +1,11 @@ +- Replaced server storage backend with Room, allowing for easier further expansion +- Add option to disable saved servers +- Improved backup handling +- Fixed desync bug while dragging servers +- Reorganized source file structure +- Updated Kotlin version +- Updated Java version +- Replaced gson with kotlinx.serialization +- Add Chinese Simplified translation (thanks @WeiguangTWK) +- Add Russian translation +- Fixed issue with provider not resetting when disabled through the dialog diff --git a/fastlane/metadata/android/en-US/changelogs/18.txt b/fastlane/metadata/android/en-US/changelogs/18.txt new file mode 100644 index 0000000..067cc8f --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/18.txt @@ -0,0 +1,12 @@ +- Add Shizuku support for newer Android versions +- Fix some crashes +- Improve Shizuku process feedback +- Fix Shizuku when not running as the primary user +- Hungarian translation by @Pacuka in https://github.com/karasevm/PrivateDNSAndroid/pull/43 +- Add Polish translation (Michal L (@chuckmichael), Eryk Michalak (gnu-ewm)) +- Add Mongolian translation (Purevbaatar Tuvshinjargal (@puujee0238)) +- Add Portuguese (Brazil) translation (ajan, Víctor Assunção (@JoaoVictorAS)) +- Add Vietnamese translation (tuấn nguyễn (@Tuan1-2-3)) +- Add French translation (papaindiatango) +- Add Tamil translation (தமிழ்நேரம் (@TamilNeram)) +- Add Turkish translation (Mustafa A. (mistiik99)) diff --git a/fastlane/metadata/android/en-US/changelogs/default.txt b/fastlane/metadata/android/en-US/changelogs/default.txt new file mode 100644 index 0000000..300e44e --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/default.txt @@ -0,0 +1,2 @@ +Changelog for latest release is available on GitHub: +https://github.com/karasevm/PrivateDNSAndroid/releases/latest \ No newline at end of file diff --git a/fastlane/metadata/android/en-US/full_description.txt b/fastlane/metadata/android/en-US/full_description.txt new file mode 100644 index 0000000..0f03cca --- /dev/null +++ b/fastlane/metadata/android/en-US/full_description.txt @@ -0,0 +1,15 @@ + + Private DNS Quick Toggle is a quick settings tile to switch your private + dns provider. + Supports any number of providers. Makes it easy to turn ad-blocking + dns servers on or off with just a single tap. + + +Permissions + + Requires WRITE_SECURE_SETTINGS permission to change the private dns settings. + The permission must be provided either with Shizuku or + + manually through adb + . + \ No newline at end of file diff --git a/fastlane/metadata/android/en-US/images/icon.png b/fastlane/metadata/android/en-US/images/icon.png new file mode 100644 index 0000000..7f6b8f6 Binary files /dev/null and b/fastlane/metadata/android/en-US/images/icon.png differ diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/01.png b/fastlane/metadata/android/en-US/images/phoneScreenshots/01.png new file mode 100644 index 0000000..8d0d479 Binary files /dev/null and b/fastlane/metadata/android/en-US/images/phoneScreenshots/01.png differ diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/02.png b/fastlane/metadata/android/en-US/images/phoneScreenshots/02.png new file mode 100644 index 0000000..e66eb7d Binary files /dev/null and b/fastlane/metadata/android/en-US/images/phoneScreenshots/02.png differ diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/03.png b/fastlane/metadata/android/en-US/images/phoneScreenshots/03.png new file mode 100644 index 0000000..7b78a4a Binary files /dev/null and b/fastlane/metadata/android/en-US/images/phoneScreenshots/03.png differ diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/04.png b/fastlane/metadata/android/en-US/images/phoneScreenshots/04.png new file mode 100644 index 0000000..bc04e45 Binary files /dev/null and b/fastlane/metadata/android/en-US/images/phoneScreenshots/04.png differ diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/05.png b/fastlane/metadata/android/en-US/images/phoneScreenshots/05.png new file mode 100644 index 0000000..abd46d3 Binary files /dev/null and b/fastlane/metadata/android/en-US/images/phoneScreenshots/05.png differ diff --git a/fastlane/metadata/android/en-US/short_description.txt b/fastlane/metadata/android/en-US/short_description.txt new file mode 100644 index 0000000..c5a798a --- /dev/null +++ b/fastlane/metadata/android/en-US/short_description.txt @@ -0,0 +1 @@ +Quick settings tile to switch active private DNS server \ No newline at end of file diff --git a/fastlane/metadata/android/en-US/title.txt b/fastlane/metadata/android/en-US/title.txt new file mode 100644 index 0000000..e82d84b --- /dev/null +++ b/fastlane/metadata/android/en-US/title.txt @@ -0,0 +1 @@ +Private DNS Quick Toggle \ No newline at end of file diff --git a/gradle.properties b/gradle.properties index 5436bb1..e6f2676 100644 --- a/gradle.properties +++ b/gradle.properties @@ -16,8 +16,9 @@ org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 # https://developer.android.com/topic/libraries/support-library/androidx-rn android.useAndroidX=true # Automatically convert third-party libraries to use AndroidX -android.enableJetifier=true +android.enableJetifier=false # Kotlin code style for this project: "official" or "obsolete": kotlin.code.style=official -android.nonTransitiveRClass=false -android.nonFinalResIds=false \ No newline at end of file +android.nonTransitiveRClass=true +android.nonFinalResIds=true +org.gradle.configuration-cache=true \ No newline at end of file diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 5599dcf..f537c92 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ #Mon Aug 16 15:36:35 MSK 2021 distributionBase=GRADLE_USER_HOME -distributionUrl=https\://services.gradle.org/distributions/gradle-8.4-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.10.2-bin.zip distributionPath=wrapper/dists zipStorePath=wrapper/dists zipStoreBase=GRADLE_USER_HOME diff --git a/readme.jpg b/readme.jpg new file mode 100644 index 0000000..2119cd5 Binary files /dev/null and b/readme.jpg differ diff --git a/readme.png b/readme.png deleted file mode 100644 index 4dee806..0000000 Binary files a/readme.png and /dev/null differ diff --git a/settings.gradle b/settings.gradle.kts similarity index 93% rename from settings.gradle rename to settings.gradle.kts index 4ccd1ba..72cb6bf 100644 --- a/settings.gradle +++ b/settings.gradle.kts @@ -6,4 +6,4 @@ dependencyResolutionManagement { } } rootProject.name = "Private DNS Quick Toggle" -include ':app' +include("app")
+ Private DNS Quick Toggle is a quick settings tile to switch your private + dns provider. + Supports any number of providers. Makes it easy to turn ad-blocking + dns servers on or off with just a single tap. +
+ Requires WRITE_SECURE_SETTINGS permission to change the private dns settings. + The permission must be provided either with Shizuku or + + manually through adb + . +