Implement settings export/import (#26)

This commit is contained in:
Maksim Karasev 2024-07-02 16:07:04 +03:00
parent 6caa1432aa
commit 3a697e32e3
5 changed files with 156 additions and 54 deletions

View file

@ -51,6 +51,7 @@ dependencies {
implementation 'com.google.android.material:material:1.12.0' implementation 'com.google.android.material:material:1.12.0'
implementation 'androidx.constraintlayout:constraintlayout:2.1.4' implementation 'androidx.constraintlayout:constraintlayout:2.1.4'
implementation 'com.google.guava:guava:33.1.0-android' implementation 'com.google.guava:guava:33.1.0-android'
implementation 'com.google.code.gson:gson:2.11.0'
def shizuku_version = '13.1.5' def shizuku_version = '13.1.5'
implementation "dev.rikka.shizuku:api:$shizuku_version" implementation "dev.rikka.shizuku:api:$shizuku_version"
@ -60,6 +61,6 @@ dependencies {
compileOnly 'dev.rikka.hidden:stub:4.3.2' compileOnly 'dev.rikka.hidden:stub:4.3.2'
testImplementation 'junit:junit:4.13.2' testImplementation 'junit:junit:4.13.2'
androidTestImplementation 'androidx.test.ext:junit:1.1.5' androidTestImplementation 'androidx.test.ext:junit:1.2.1'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1' androidTestImplementation 'androidx.test.espresso:espresso-core:3.6.1'
} }

View file

@ -1,7 +1,10 @@
package ru.karasevm.privatednstoggle package ru.karasevm.privatednstoggle
import android.Manifest 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.Intent
import android.content.SharedPreferences import android.content.SharedPreferences
import android.content.pm.IPackageManager import android.content.pm.IPackageManager
@ -15,11 +18,17 @@ import android.util.Log
import android.view.Menu import android.view.Menu
import android.widget.Toast import android.widget.Toast
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import androidx.core.app.ActivityCompat
import androidx.recyclerview.widget.ItemTouchHelper import androidx.recyclerview.widget.ItemTouchHelper
import androidx.recyclerview.widget.ItemTouchHelper.DOWN import androidx.recyclerview.widget.ItemTouchHelper.DOWN
import androidx.recyclerview.widget.ItemTouchHelper.UP import androidx.recyclerview.widget.ItemTouchHelper.UP
import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView 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 org.lsposed.hiddenapibypass.HiddenApiBypass
import rikka.shizuku.Shizuku import rikka.shizuku.Shizuku
import rikka.shizuku.ShizukuBinderWrapper import rikka.shizuku.ShizukuBinderWrapper
@ -28,6 +37,8 @@ import rikka.shizuku.SystemServiceHelper
import ru.karasevm.privatednstoggle.databinding.ActivityMainBinding import ru.karasevm.privatednstoggle.databinding.ActivityMainBinding
import ru.karasevm.privatednstoggle.utils.PreferenceHelper import ru.karasevm.privatednstoggle.utils.PreferenceHelper
import ru.karasevm.privatednstoggle.utils.PreferenceHelper.dns_servers 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, class MainActivity : AppCompatActivity(), AddServerDialogFragment.NoticeDialogListener,
@ -38,10 +49,11 @@ class MainActivity : AppCompatActivity(), AddServerDialogFragment.NoticeDialogLi
private var items = mutableListOf<String>() private var items = mutableListOf<String>()
private lateinit var sharedPrefs: SharedPreferences private lateinit var sharedPrefs: SharedPreferences
private lateinit var adapter: RecyclerAdapter private lateinit var adapter: RecyclerAdapter
private lateinit var clipboard: ClipboardManager
private lateinit var gson: Gson
private val itemTouchHelper by lazy { private val itemTouchHelper by lazy {
val simpleItemTouchCallback = val simpleItemTouchCallback = object : ItemTouchHelper.SimpleCallback(UP or DOWN, 0) {
object : ItemTouchHelper.SimpleCallback(UP or DOWN, 0) {
override fun onMove( override fun onMove(
recyclerView: RecyclerView, recyclerView: RecyclerView,
@ -55,8 +67,7 @@ class MainActivity : AppCompatActivity(), AddServerDialogFragment.NoticeDialogLi
override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) {} override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) {}
override fun onSelectedChanged( override fun onSelectedChanged(
viewHolder: RecyclerView.ViewHolder?, viewHolder: RecyclerView.ViewHolder?, actionState: Int
actionState: Int
) { ) {
super.onSelectedChanged(viewHolder, actionState) super.onSelectedChanged(viewHolder, actionState)
if (actionState == ItemTouchHelper.ACTION_STATE_DRAG) { if (actionState == ItemTouchHelper.ACTION_STATE_DRAG) {
@ -70,8 +81,7 @@ class MainActivity : AppCompatActivity(), AddServerDialogFragment.NoticeDialogLi
} }
override fun clearView( override fun clearView(
recyclerView: RecyclerView, recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder
viewHolder: RecyclerView.ViewHolder
) { ) {
super.clearView(recyclerView, viewHolder) super.clearView(recyclerView, viewHolder)
viewHolder.itemView.apply { viewHolder.itemView.apply {
@ -98,6 +108,8 @@ class MainActivity : AppCompatActivity(), AddServerDialogFragment.NoticeDialogLi
binding.recyclerView.layoutManager = linearLayoutManager binding.recyclerView.layoutManager = linearLayoutManager
sharedPrefs = PreferenceHelper.defaultPreference(this) sharedPrefs = PreferenceHelper.defaultPreference(this)
clipboard = getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
gson = GsonBuilder().setObjectToNumberStrategy(ToNumberPolicy.LONG_OR_DOUBLE).create()
items = sharedPrefs.dns_servers items = sharedPrefs.dns_servers
if (items[0] == "") { if (items[0] == "") {
@ -108,9 +120,9 @@ class MainActivity : AppCompatActivity(), AddServerDialogFragment.NoticeDialogLi
val newFragment = DeleteServerDialogFragment(position) val newFragment = DeleteServerDialogFragment(position)
newFragment.show(supportFragmentManager, "delete_server") newFragment.show(supportFragmentManager, "delete_server")
} }
adapter.onItemsChanged = { swapedItems -> adapter.onItemsChanged = { swappedItems ->
items = swapedItems items = swappedItems
sharedPrefs.dns_servers = swapedItems sharedPrefs.dns_servers = swappedItems
} }
adapter.onDragStart = { viewHolder -> adapter.onDragStart = { viewHolder ->
itemTouchHelper.startDrag(viewHolder) itemTouchHelper.startDrag(viewHolder)
@ -133,6 +145,54 @@ class MainActivity : AppCompatActivity(), AddServerDialogFragment.NoticeDialogLi
true 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<Map<String, Any>>() {}.type
val data: Map<String, Any> = 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 -> { R.id.options -> {
val newFragment = OptionsDialogFragment() val newFragment = OptionsDialogFragment()
newFragment.show(supportFragmentManager, "options") 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() { override fun onDestroy() {
super.onDestroy() super.onDestroy()
Shizuku.removeRequestPermissionResultListener(this::onRequestPermissionResult) Shizuku.removeRequestPermissionResultListener(this::onRequestPermissionResult)
@ -224,9 +301,7 @@ class MainActivity : AppCompatActivity(), AddServerDialogFragment.NoticeDialogLi
val pm = IPermissionManager.Stub.asInterface(binder) val pm = IPermissionManager.Stub.asInterface(binder)
runCatching { runCatching {
pm.grantRuntimePermission( pm.grantRuntimePermission(
packageName, packageName, Manifest.permission.WRITE_SECURE_SETTINGS, 0
Manifest.permission.WRITE_SECURE_SETTINGS,
0
) )
}.onFailure { _ -> }.onFailure { _ ->
if (Build.VERSION.SDK_INT >= 34) { if (Build.VERSION.SDK_INT >= 34) {
@ -242,9 +317,7 @@ class MainActivity : AppCompatActivity(), AddServerDialogFragment.NoticeDialogLi
val binder = ShizukuBinderWrapper(SystemServiceHelper.getSystemService("package")) val binder = ShizukuBinderWrapper(SystemServiceHelper.getSystemService("package"))
val pm = IPackageManager.Stub.asInterface(binder) val pm = IPackageManager.Stub.asInterface(binder)
pm.grantRuntimePermission( pm.grantRuntimePermission(
packageName, packageName, Manifest.permission.WRITE_SECURE_SETTINGS, 0
Manifest.permission.WRITE_SECURE_SETTINGS,
0
) )
} }
}.onFailure { e -> }.onFailure { e ->
@ -252,8 +325,7 @@ class MainActivity : AppCompatActivity(), AddServerDialogFragment.NoticeDialogLi
}.also { }.also {
if (checkSelfPermission(Manifest.permission.WRITE_SECURE_SETTINGS) != PackageManager.PERMISSION_GRANTED) { if (checkSelfPermission(Manifest.permission.WRITE_SECURE_SETTINGS) != PackageManager.PERMISSION_GRANTED) {
val browserIntent = Intent( val browserIntent = Intent(
Intent.ACTION_VIEW, Intent.ACTION_VIEW, Uri.parse("https://karasevm.github.io/PrivateDNSAndroid/")
Uri.parse("https://karasevm.github.io/PrivateDNSAndroid/")
) )
startActivity(browserIntent) startActivity(browserIntent)
finish() finish()
@ -262,7 +334,6 @@ class MainActivity : AppCompatActivity(), AddServerDialogFragment.NoticeDialogLi
} }
@SuppressLint("PrivateApi")
override fun onRequestPermissionResult(requestCode: Int, grantResult: Int) { override fun onRequestPermissionResult(requestCode: Int, grantResult: Int) {
val isGranted = grantResult == PackageManager.PERMISSION_GRANTED val isGranted = grantResult == PackageManager.PERMISSION_GRANTED
@ -270,8 +341,7 @@ class MainActivity : AppCompatActivity(), AddServerDialogFragment.NoticeDialogLi
grantPermissionWithShizuku() grantPermissionWithShizuku()
} else if (checkSelfPermission(Manifest.permission.WRITE_SECURE_SETTINGS) != PackageManager.PERMISSION_GRANTED) { } else if (checkSelfPermission(Manifest.permission.WRITE_SECURE_SETTINGS) != PackageManager.PERMISSION_GRANTED) {
val browserIntent = Intent( val browserIntent = Intent(
Intent.ACTION_VIEW, Intent.ACTION_VIEW, Uri.parse("https://karasevm.github.io/PrivateDNSAndroid/")
Uri.parse("https://karasevm.github.io/PrivateDNSAndroid/")
) )
startActivity(browserIntent) startActivity(browserIntent)
finish() finish()

View file

@ -26,7 +26,7 @@ object PreferenceHelper {
is Boolean -> putBoolean(key, value) is Boolean -> putBoolean(key, value)
is Long -> putLong(key, value) is Long -> putLong(key, value)
is Float -> putFloat(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<String, Any>) {
editMe {
map.forEach { (key, value) ->
if (value is Number) {
it.put(key to value.toInt())
return@forEach
}
it.put(key to value)
}
}
}
} }

View file

@ -7,6 +7,12 @@
app:showAsAction="ifRoom" app:showAsAction="ifRoom"
android:icon="@drawable/ic_baseline_settings_24" android:icon="@drawable/ic_baseline_settings_24"
/> />
<item android:id="@+id/import_settings"
android:title="@string/menu_import"
app:showAsAction="never" />
<item android:id="@+id/export_settings"
android:title="@string/menu_export"
app:showAsAction="never" />
<item android:id="@+id/privacy_policy" <item android:id="@+id/privacy_policy"
android:title="@string/menu_privacy_policy" android:title="@string/menu_privacy_policy"
app:showAsAction="never" /> app:showAsAction="never" />

View file

@ -30,4 +30,10 @@
<string name="set_to_provider_toast">Private DNS set to %1$s</string> <string name="set_to_provider_toast">Private DNS set to %1$s</string>
<string name="require_unlock_setting">Require unlocking the device to change server</string> <string name="require_unlock_setting">Require unlocking the device to change server</string>
<string name="a11y_drag_handle">Drag handle</string> <string name="a11y_drag_handle">Drag handle</string>
<string name="menu_import">Import from clipboard</string>
<string name="menu_export">Export to clipboard</string>
<string name="import_success">Imported</string>
<string name="import_failure">Import failed</string>
<string name="import_failure_json">Import failed, malformed JSON</string>
<string name="copy_success">Copied</string>
</resources> </resources>