diff --git a/app/build.gradle b/app/build.gradle index 1b43aa7..2a02e63 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -51,6 +51,7 @@ dependencies { implementation 'com.google.android.material:material:1.12.0' implementation 'androidx.constraintlayout:constraintlayout:2.1.4' implementation 'com.google.guava:guava:33.1.0-android' + implementation 'com.google.code.gson:gson:2.11.0' def shizuku_version = '13.1.5' implementation "dev.rikka.shizuku:api:$shizuku_version" @@ -60,6 +61,6 @@ dependencies { 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' + 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/src/main/java/ru/karasevm/privatednstoggle/MainActivity.kt b/app/src/main/java/ru/karasevm/privatednstoggle/MainActivity.kt index f19a122..1792e30 100644 --- a/app/src/main/java/ru/karasevm/privatednstoggle/MainActivity.kt +++ b/app/src/main/java/ru/karasevm/privatednstoggle/MainActivity.kt @@ -1,7 +1,10 @@ package ru.karasevm.privatednstoggle import android.Manifest -import android.annotation.SuppressLint +import android.content.ClipData +import android.content.ClipDescription.MIMETYPE_TEXT_PLAIN +import android.content.ClipboardManager +import android.content.Context import android.content.Intent import android.content.SharedPreferences import android.content.pm.IPackageManager @@ -15,11 +18,17 @@ import android.util.Log import android.view.Menu import android.widget.Toast import androidx.appcompat.app.AppCompatActivity +import androidx.core.app.ActivityCompat 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 com.google.gson.Gson +import com.google.gson.GsonBuilder +import com.google.gson.JsonSyntaxException +import com.google.gson.ToNumberPolicy +import com.google.gson.reflect.TypeToken import org.lsposed.hiddenapibypass.HiddenApiBypass import rikka.shizuku.Shizuku import rikka.shizuku.ShizukuBinderWrapper @@ -28,6 +37,8 @@ import rikka.shizuku.SystemServiceHelper import ru.karasevm.privatednstoggle.databinding.ActivityMainBinding import ru.karasevm.privatednstoggle.utils.PreferenceHelper import ru.karasevm.privatednstoggle.utils.PreferenceHelper.dns_servers +import ru.karasevm.privatednstoggle.utils.PreferenceHelper.export +import ru.karasevm.privatednstoggle.utils.PreferenceHelper.import class MainActivity : AppCompatActivity(), AddServerDialogFragment.NoticeDialogListener, @@ -38,50 +49,49 @@ class MainActivity : AppCompatActivity(), AddServerDialogFragment.NoticeDialogLi private var items = mutableListOf() private lateinit var sharedPrefs: SharedPreferences private lateinit var adapter: RecyclerAdapter + private lateinit var clipboard: ClipboardManager + private lateinit var gson: Gson private val itemTouchHelper by lazy { - val simpleItemTouchCallback = - object : ItemTouchHelper.SimpleCallback(UP or DOWN, 0) { + val simpleItemTouchCallback = object : ItemTouchHelper.SimpleCallback(UP or DOWN, 0) { - override fun onMove( - recyclerView: RecyclerView, - viewHolder: RecyclerView.ViewHolder, - target: RecyclerView.ViewHolder - ): Boolean { - adapter.onItemMove(viewHolder.adapterPosition, target.adapterPosition) - return true - } + override fun onMove( + recyclerView: RecyclerView, + viewHolder: RecyclerView.ViewHolder, + target: RecyclerView.ViewHolder + ): Boolean { + adapter.onItemMove(viewHolder.adapterPosition, target.adapterPosition) + return true + } - override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) {} + 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) + 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) + } + } + } ItemTouchHelper(simpleItemTouchCallback) } @@ -98,6 +108,8 @@ class MainActivity : AppCompatActivity(), AddServerDialogFragment.NoticeDialogLi binding.recyclerView.layoutManager = linearLayoutManager sharedPrefs = PreferenceHelper.defaultPreference(this) + clipboard = getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + gson = GsonBuilder().setObjectToNumberStrategy(ToNumberPolicy.LONG_OR_DOUBLE).create() items = sharedPrefs.dns_servers if (items[0] == "") { @@ -108,9 +120,9 @@ class MainActivity : AppCompatActivity(), AddServerDialogFragment.NoticeDialogLi val newFragment = DeleteServerDialogFragment(position) newFragment.show(supportFragmentManager, "delete_server") } - adapter.onItemsChanged = { swapedItems -> - items = swapedItems - sharedPrefs.dns_servers = swapedItems + adapter.onItemsChanged = { swappedItems -> + items = swappedItems + sharedPrefs.dns_servers = swappedItems } adapter.onDragStart = { viewHolder -> itemTouchHelper.startDrag(viewHolder) @@ -133,6 +145,54 @@ class MainActivity : AppCompatActivity(), AddServerDialogFragment.NoticeDialogLi true } + R.id.export_settings -> { + val data = sharedPrefs.export() + val jsonData = gson.toJson(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( + this, getString(R.string.copy_success), Toast.LENGTH_SHORT + ).show() + true + } + + R.id.import_settings -> { + val clipData = clipboard.primaryClip?.getItemAt(0) + val textData = clipData?.text + + if (textData != null) { + runCatching { + val jsonData = textData.toString() + val objectType = object : TypeToken>() {}.type + val data: Map = gson.fromJson(jsonData, objectType) + sharedPrefs.import(data) + }.onSuccess { + Toast.makeText( + this, getString(R.string.import_success), Toast.LENGTH_SHORT + ).show() + ActivityCompat.recreate(this) + }.onFailure { exception -> + Log.e("IMPORT", "Import failed", exception) + when (exception) { + is JsonSyntaxException -> { + Toast.makeText( + this, + getString(R.string.import_failure_json), + Toast.LENGTH_SHORT + ).show() + } + + else -> { + Toast.makeText( + this, getString(R.string.import_failure), Toast.LENGTH_SHORT + ).show() + } + } + } + } + true + } + R.id.options -> { val newFragment = OptionsDialogFragment() newFragment.show(supportFragmentManager, "options") @@ -184,6 +244,23 @@ class MainActivity : AppCompatActivity(), AddServerDialogFragment.NoticeDialogLi } } + 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) + + // 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) @@ -224,9 +301,7 @@ class MainActivity : AppCompatActivity(), AddServerDialogFragment.NoticeDialogLi val pm = IPermissionManager.Stub.asInterface(binder) runCatching { pm.grantRuntimePermission( - packageName, - Manifest.permission.WRITE_SECURE_SETTINGS, - 0 + packageName, Manifest.permission.WRITE_SECURE_SETTINGS, 0 ) }.onFailure { _ -> if (Build.VERSION.SDK_INT >= 34) { @@ -242,9 +317,7 @@ class MainActivity : AppCompatActivity(), AddServerDialogFragment.NoticeDialogLi val binder = ShizukuBinderWrapper(SystemServiceHelper.getSystemService("package")) val pm = IPackageManager.Stub.asInterface(binder) pm.grantRuntimePermission( - packageName, - Manifest.permission.WRITE_SECURE_SETTINGS, - 0 + packageName, Manifest.permission.WRITE_SECURE_SETTINGS, 0 ) } }.onFailure { e -> @@ -252,8 +325,7 @@ class MainActivity : AppCompatActivity(), AddServerDialogFragment.NoticeDialogLi }.also { if (checkSelfPermission(Manifest.permission.WRITE_SECURE_SETTINGS) != PackageManager.PERMISSION_GRANTED) { val browserIntent = Intent( - Intent.ACTION_VIEW, - Uri.parse("https://karasevm.github.io/PrivateDNSAndroid/") + Intent.ACTION_VIEW, Uri.parse("https://karasevm.github.io/PrivateDNSAndroid/") ) startActivity(browserIntent) finish() @@ -262,7 +334,6 @@ class MainActivity : AppCompatActivity(), AddServerDialogFragment.NoticeDialogLi } - @SuppressLint("PrivateApi") override fun onRequestPermissionResult(requestCode: Int, grantResult: Int) { val isGranted = grantResult == PackageManager.PERMISSION_GRANTED @@ -270,8 +341,7 @@ class MainActivity : AppCompatActivity(), AddServerDialogFragment.NoticeDialogLi grantPermissionWithShizuku() } else if (checkSelfPermission(Manifest.permission.WRITE_SECURE_SETTINGS) != PackageManager.PERMISSION_GRANTED) { val browserIntent = Intent( - Intent.ACTION_VIEW, - Uri.parse("https://karasevm.github.io/PrivateDNSAndroid/") + Intent.ACTION_VIEW, Uri.parse("https://karasevm.github.io/PrivateDNSAndroid/") ) startActivity(browserIntent) finish() diff --git a/app/src/main/java/ru/karasevm/privatednstoggle/utils/SharedPrefUtils.kt b/app/src/main/java/ru/karasevm/privatednstoggle/utils/SharedPrefUtils.kt index 87cfa96..8e97931 100644 --- a/app/src/main/java/ru/karasevm/privatednstoggle/utils/SharedPrefUtils.kt +++ b/app/src/main/java/ru/karasevm/privatednstoggle/utils/SharedPrefUtils.kt @@ -26,7 +26,7 @@ object PreferenceHelper { 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") + else -> error("Only primitive types can be stored in SharedPreferences, got ${value.javaClass}") } } @@ -54,4 +54,23 @@ object PreferenceHelper { } } + // export all the preferences + fun SharedPreferences.export() = mapOf( + DNS_SERVERS to getString(DNS_SERVERS, ""), + AUTO_MODE to autoMode, + REQUIRE_UNLOCK to requireUnlock + ) + + // import all the preferences + fun SharedPreferences.import(map: Map) { + editMe { + map.forEach { (key, value) -> + if (value is Number) { + it.put(key to value.toInt()) + return@forEach + } + it.put(key to value) + } + } + } } diff --git a/app/src/main/res/menu/menu_main.xml b/app/src/main/res/menu/menu_main.xml index bce5f6b..02dfde5 100644 --- a/app/src/main/res/menu/menu_main.xml +++ b/app/src/main/res/menu/menu_main.xml @@ -7,6 +7,12 @@ app:showAsAction="ifRoom" android:icon="@drawable/ic_baseline_settings_24" /> + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 3f3912e..80fe947 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -30,4 +30,10 @@ Private DNS set to %1$s Require unlocking the device to change server Drag handle + Import from clipboard + Export to clipboard + Imported + Import failed + Import failed, malformed JSON + Copied \ No newline at end of file