commit 30fa1216cf865a558deafdd18a2525934ced9e70 truex <truex@equwal.com> 2026-09-21 12:07:03 -0700 Keep the settings across an uninstall, and pin the stored formats An update always kept the settings, because they are in the data of the app. An uninstall removed them. hasFragileUserData makes Android ask at uninstall whether to keep the data. AutoBackup writes the export text to Documents/Rebind/rebind-settings.json a few seconds after each change, and once at start. It needs no permission. Import opens that folder. StoredFormatTest holds the bindings and the settings file as the 0.0.x versions wrote them. A change that cannot read them fails the test.
CHANGELOG.md | 10 +++ app/src/main/AndroidManifest.xml | 5 ++ .../equwal/assistkey/engine/KeyFilterService.kt | 1 + .../java/dev/equwal/assistkey/store/AutoBackup.kt | 98 ++++++++++++++++++++++ .../java/dev/equwal/assistkey/ui/BackupActivity.kt | 24 ++++-- .../java/dev/equwal/assistkey/ui/MainActivity.kt | 2 + .../dev/equwal/assistkey/store/StoredFormatTest.kt | 85 +++++++++++++++++++ play/PRIVACY.md | 7 +- 8 files changed, 223 insertions(+), 9 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md index 3870891..2df3aca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,15 @@ # Changelog +## Unreleased + +- **Settings are safe across versions and across an uninstall.** An update + always kept the settings: they are in the data of the app. An uninstall + removed them. Now Android asks at uninstall whether to keep the data + (`hasFragileUserData`), and Rebind keeps an automatic copy of the settings in + `Documents/Rebind/rebind-settings.json`, which an uninstall does not touch. + Import opens that folder. `StoredFormatTest` holds the texts that every + 0.0.x version stored, so a new version cannot stop reading them unseen. + ## 0.0.15-alpha - 2026-09-21 Version code 15. diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 3505deb..a8cae91 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -46,8 +46,13 @@ <package android:name="com.viwoods.settings" /> </queries> + <!-- + hasFragileUserData: on uninstall, Android asks the user whether to keep + the data of the app. With the data kept, a new install has every setting. + --> <application android:allowBackup="true" + android:hasFragileUserData="true" android:icon="@mipmap/ic_launcher" android:label="@string/app_name" android:supportsRtl="true" diff --git a/app/src/main/java/dev/equwal/assistkey/engine/KeyFilterService.kt b/app/src/main/java/dev/equwal/assistkey/engine/KeyFilterService.kt index 740bbf9..842d3c7 100644 --- a/app/src/main/java/dev/equwal/assistkey/engine/KeyFilterService.kt +++ b/app/src/main/java/dev/equwal/assistkey/engine/KeyFilterService.kt @@ -40,6 +40,7 @@ class KeyFilterService : AccessibilityService(), GestureEngine.Host { super.onServiceConnected() engine = GestureEngine(this, Store.timing(this)) ServiceHolder.service = this + dev.equwal.assistkey.store.AutoBackup.watch(this) // The service starts at boot and may run for weeks without the settings // screen ever opening, so it keeps the licence fresh on its own. PlayBilling.refresh(this) { syncPower() } diff --git a/app/src/main/java/dev/equwal/assistkey/store/AutoBackup.kt b/app/src/main/java/dev/equwal/assistkey/store/AutoBackup.kt new file mode 100644 index 0000000..df96489 --- /dev/null +++ b/app/src/main/java/dev/equwal/assistkey/store/AutoBackup.kt @@ -0,0 +1,98 @@ +package dev.equwal.assistkey.store + +import android.content.ContentValues +import android.content.Context +import android.content.SharedPreferences +import android.os.Environment +import android.os.Handler +import android.os.HandlerThread +import android.provider.MediaStore +import android.util.Log +import dev.equwal.assistkey.BuildConfig +import dev.equwal.assistkey.device.Device + +/** + * A copy of the settings outside the app, kept up to date by itself. + * + * An update keeps the settings: they are in the data of the app, and Android + * keeps that data when a new version goes over the old one. An uninstall + * removes the data. This copy is in the shared Documents folder, which an + * uninstall does not touch, so the user can import it after a new install. + * + * The copy holds what an export holds, and nothing else. See [SettingsFile]. + * It needs no permission: an app may write its own file to Documents. + */ +object AutoBackup { + + const val FOLDER = "Rebind" + const val FILE_NAME = "rebind-settings.json" + + /** For the user: where the copy is. */ + val PLACE = Environment.DIRECTORY_DOCUMENTS + "/" + FOLDER + "/" + FILE_NAME + + private const val TAG = "AssistKey" + private const val DELAY_MS = 3000L + + private val thread by lazy { HandlerThread("rebind-backup").apply { start() } } + private val worker by lazy { Handler(thread.looper) } + + // SharedPreferences holds its listeners weakly. These references keep them alive. + private val listeners = ArrayList<SharedPreferences.OnSharedPreferenceChangeListener>() + + /** The settings as one JSON text, the same text that Export writes. */ + fun export(c: Context): String { + val settings = SettingsFile.ALLOWED.keys.associateWith { file -> + c.getSharedPreferences(file, Context.MODE_PRIVATE).all + } + return SettingsFile.encode(settings, BuildConfig.VERSION_NAME, Device.name) + } + + /** + * Starts to watch the settings. Each change writes the copy again, a few + * seconds later, so that a run of changes makes one write. Safe to call + * more than once. + */ + @Synchronized + fun watch(context: Context) { + if (listeners.isNotEmpty()) return + val app = context.applicationContext + SettingsFile.ALLOWED.keys.forEach { file -> + val l = SharedPreferences.OnSharedPreferenceChangeListener { _, _ -> schedule(app) } + listeners += l + app.getSharedPreferences(file, Context.MODE_PRIVATE).registerOnSharedPreferenceChangeListener(l) + } + // One write at the start, so that a user who only updates has a copy too. + schedule(app) + } + + private val token = Any() + + private fun schedule(app: Context) { + worker.removeCallbacksAndMessages(token) + worker.postDelayed({ write(app) }, token, DELAY_MS) + } + + /** Writes the copy now. False when the device refused. The app works without the copy. */ + fun write(c: Context): Boolean = runCatching { + val resolver = c.contentResolver + val files = MediaStore.Files.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY) + val path = Environment.DIRECTORY_DOCUMENTS + "/" + FOLDER + "/" + // The file of this install, if there is one. After a new install the old + // file belongs to nobody, and Android gives the new file a name of its own. + val mine = resolver.query( + files, arrayOf(MediaStore.MediaColumns._ID), + MediaStore.MediaColumns.RELATIVE_PATH + "=? AND " + MediaStore.MediaColumns.DISPLAY_NAME + "=?", + arrayOf(path, FILE_NAME), null + )?.use { if (it.moveToFirst()) android.content.ContentUris.withAppendedId(files, it.getLong(0)) else null } + val target = mine ?: resolver.insert( + files, + ContentValues().apply { + put(MediaStore.MediaColumns.DISPLAY_NAME, FILE_NAME) + put(MediaStore.MediaColumns.MIME_TYPE, "application/json") + put(MediaStore.MediaColumns.RELATIVE_PATH, path) + } + ) ?: return false + resolver.openOutputStream(target, "wt")?.use { it.write(export(c).toByteArray()) } ?: return false + true + }.onFailure { Log.w(TAG, "settings copy not written", it) }.getOrDefault(false) +} diff --git a/app/src/main/java/dev/equwal/assistkey/ui/BackupActivity.kt b/app/src/main/java/dev/equwal/assistkey/ui/BackupActivity.kt index 2a815d0..e9aad0e 100644 --- a/app/src/main/java/dev/equwal/assistkey/ui/BackupActivity.kt +++ b/app/src/main/java/dev/equwal/assistkey/ui/BackupActivity.kt @@ -12,11 +12,13 @@ import dev.equwal.assistkey.device.Device import dev.equwal.assistkey.engine.KeyFilterService import dev.equwal.assistkey.model.Trigger import dev.equwal.assistkey.route.ServiceHolder +import dev.equwal.assistkey.store.AutoBackup import dev.equwal.assistkey.store.SettingsFile import dev.equwal.assistkey.store.Store import dev.equwal.assistkey.ui.Ui.button import dev.equwal.assistkey.ui.Ui.header import dev.equwal.assistkey.ui.Ui.note +import dev.equwal.assistkey.ui.Ui.row import org.json.JSONObject /** @@ -32,6 +34,11 @@ class BackupActivity : Activity() { const val OPEN = 2 const val FILE_NAME = "assistkey-settings.json" + /** Documents/Rebind, as the file picker of Android names it. */ + val COPY_FOLDER: Uri = android.provider.DocumentsContract.buildDocumentUri( + "com.android.externalstorage.documents", "primary:Documents/" + AutoBackup.FOLDER + ) + } override fun onResume() { @@ -42,6 +49,9 @@ class BackupActivity : Activity() { private fun build() { val col = Ui.page(this, "Export and import") + col.header("Automatic copy") + col.row(AutoBackup.PLACE, "Kept up to date. It stays after an uninstall.", enabled = false) + col.header("Export") col.button("Save to a file") { start( @@ -64,7 +74,12 @@ class BackupActivity : Activity() { col.header("Import") col.button("Open a file") { - start(Intent(Intent.ACTION_OPEN_DOCUMENT).addCategory(Intent.CATEGORY_OPENABLE).setType("*/*"), OPEN) + start( + Intent(Intent.ACTION_OPEN_DOCUMENT).addCategory(Intent.CATEGORY_OPENABLE).setType("*/*") + // Start in the folder of the automatic copy. + .putExtra(android.provider.DocumentsContract.EXTRA_INITIAL_URI, COPY_FOLDER), + OPEN + ) } col.button("Paste from the clipboard") { val clip = getSystemService(android.content.ClipboardManager::class.java) @@ -96,12 +111,7 @@ class BackupActivity : Activity() { // ---- export --------------------------------------------------------------------------------- - private fun export(): String { - val settings = SettingsFile.ALLOWED.keys.associateWith { file -> - getSharedPreferences(file, Context.MODE_PRIVATE).all - } - return SettingsFile.encode(settings, BuildConfig.VERSION_NAME, Device.name) - } + private fun export(): String = AutoBackup.export(this) // ---- import --------------------------------------------------------------------------------- diff --git a/app/src/main/java/dev/equwal/assistkey/ui/MainActivity.kt b/app/src/main/java/dev/equwal/assistkey/ui/MainActivity.kt index a45e622..46a43fd 100644 --- a/app/src/main/java/dev/equwal/assistkey/ui/MainActivity.kt +++ b/app/src/main/java/dev/equwal/assistkey/ui/MainActivity.kt @@ -23,6 +23,7 @@ import dev.equwal.assistkey.model.Trigger import dev.equwal.assistkey.native.NavNative import dev.equwal.assistkey.setup.DeviceView import dev.equwal.assistkey.setup.GuidedSetupActivity +import dev.equwal.assistkey.store.AutoBackup import dev.equwal.assistkey.store.Store import dev.equwal.assistkey.ui.Ui.dp import dev.equwal.assistkey.ui.Ui.header @@ -53,6 +54,7 @@ class MainActivity : Activity() { // Finds the buttons and the capabilities of this device. It runs at // first start, and again after a firmware or app change. Detect.refreshIfStale(this) + AutoBackup.watch(this) PermissionsActivity.showOnce(this) } diff --git a/app/src/test/java/dev/equwal/assistkey/store/StoredFormatTest.kt b/app/src/test/java/dev/equwal/assistkey/store/StoredFormatTest.kt new file mode 100644 index 0000000..559a74f --- /dev/null +++ b/app/src/test/java/dev/equwal/assistkey/store/StoredFormatTest.kt @@ -0,0 +1,85 @@ +package dev.equwal.assistkey.store + +import dev.equwal.assistkey.model.ActionKind +import dev.equwal.assistkey.model.ActionSpec +import dev.equwal.assistkey.model.GestureType +import dev.equwal.assistkey.model.HwKey +import dev.equwal.assistkey.model.Trigger +import org.json.JSONObject +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Test + +/** + * A new version must read what an old version stored. These are the texts that + * the 0.0.x versions wrote. If a change makes one of them unreadable, the + * settings of every user are lost at the update. Do not edit the texts: add + * a new one for a new format. + */ +class StoredFormatTest { + + /** The "bindings" preference, as 0.0.7 to 0.0.15 wrote it. One entry for each kind of action. */ + private val bindings = """ + {"power:tap:1":{"kind":"GLOBAL","payload":"BACK","label":"Back"}, + "power:hold:1":{"kind":"GLOBAL","payload":"HOME_CLOSE_IME","label":"Home"}, + "power:tap:2":{"kind":"LAUNCH_COMPONENT","payload":"dev.equwal.assistkey/dev.equwal.assistkey.home.RecentsActivity","label":"Recent apps"}, + "power:tap:3":{"kind":"GLOBAL","payload":"LOCK_SCREEN","label":"Lock screen"}, + "vol_up:tap:1":{"kind":"SWIPE","payload":"right","label":"Previous page"}, + "vol_down:tap:1":{"kind":"SCROLL","payload":"forward","label":"Scroll"}, + "ai:tap:2":{"kind":"VOICE","payload":"","label":"Voice typing"}, + "ai:hold:1":{"kind":"DIM","payload":"toggle","label":""}, + "ai+vol_up:tap:1":{"kind":"MENU","payload":"[]","label":"Menu"}, + "vol_down+power:tap:1":{"kind":"LAUNCH_APP","payload":"org.koreader.launcher","label":"KOReader"}, + "screen:tap:1":{"kind":"LAUNCH_COMPONENT","payload":"com.viwoods.viwoodsai/com.wisky.wiskyai.WebViewAiActivity?recodeKey=recode_key_start","label":"AI voice prompt"}, + "page_down:tap:1":{"kind":"NAV","payload":"toggle_bar","label":"Show or hide the button bar"}, + "page_up:tap:1":{"kind":"MEDIA","payload":"play_pause","label":"Play or pause"}, + "camera:tap:1":{"kind":"VOLUME","payload":"raise","label":"Volume up"}, + "f2:tap:1":{"kind":"LAUNCH_ACTION","payload":"dev.equwal.inkdim.TOGGLE","label":"TOGGLE"}, + "f3:tap:1":{"kind":"BROADCAST","payload":"com.example.PING","label":"PING"}, + "f4:tap:1":{"kind":"NONE","payload":"","label":"Disabled"}} + """.trimIndent() + + @Test fun `every trigger and action that an old version stored still reads`() { + val root = JSONObject(bindings) + val kinds = HashSet<ActionKind>() + root.keys().forEach { id -> + val trigger = Trigger.parse(id) + assertNotNull("trigger " + id, trigger) + assertEquals(id, trigger!!.id) + val spec = ActionSpec.fromJson(root.getJSONObject(id)) + assertEquals(root.getJSONObject(id).getString("kind"), spec.kind.name) + assertEquals(root.getJSONObject(id).getString("payload"), spec.payload) + kinds += spec.kind + } + // PASS_THROUGH is never stored: it means "no binding". + assertEquals(ActionKind.entries.toSet() - ActionKind.PASS_THROUGH, kinds) + } + + @Test fun `the tokens of the keys do not change`() { + assertEquals( + listOf("ai", "vol_up", "vol_down", "page_up", "page_down", "camera", "focus", "assist", + "headset", "mute", "f2", "f3", "f4", "power", "screen"), + HwKey.entries.map { it.token } + ) + assertEquals(Trigger(setOf(HwKey.POWER), GestureType.HOLD), Trigger.parse("power:hold:1")) + } + + /** A settings file as 0.0.9 exported it, with a home screen block that no longer exists. */ + @Test fun `an export of an old version still imports, and what is gone is skipped`() { + val file = """ + {"app":"AssistKey","format":1,"version":"0.0.9-alpha-full","device":"Viwoods AiPaper Reader", + "settings":{ + "assistkey":{"bindings":{"t":"json","v":{"ai:tap:2":{"kind":"VOICE","payload":"","label":"Voice typing"}}}, + "hold_ms":{"t":"i","v":450},"multitap_ms":{"t":"i","v":280},"chord_ms":{"t":"i","v":140}}, + "assistkey_home":{"favourites":{"t":"s","v":"org.koreader.launcher"},"clock":{"t":"b","v":true}}, + "assistkey_nav":{"buttons":{"t":"b","v":true},"gestures":{"t":"b","v":false},"keys":{"t":"b","v":true}}, + "assistkey_voice":{"engine":{"t":"s","v":"org.woheller69.whisper/com.whispertflite.WhisperRecognitionService"}, + "language":{"t":"s","v":"ja-JP"}}, + "assistkey_power":{"wanted":{"t":"b","v":true}}}} + """.trimIndent() + val read = SettingsFile.decode(file) + assertEquals(setOf("assistkey", "assistkey_nav", "assistkey_voice", "assistkey_power"), read.keys) + assertEquals(450, read.getValue("assistkey")["hold_ms"]) + assertEquals("ja-JP", read.getValue("assistkey_voice")["language"]) + } +} diff --git a/play/PRIVACY.md b/play/PRIVACY.md index 2473fa0..f5fc45e 100644 --- a/play/PRIVACY.md +++ b/play/PRIVACY.md @@ -23,8 +23,11 @@ service asks Android which part of the current window can scroll, so that it can scroll it. It does not read, record or transmit what the window contains. **Your settings.** Your key bindings, timing preferences and which capture -channels are on are saved in the app's private storage on your device. They -leave the device only through Android's own backup, if you have it on. +channels are on are saved in the app's private storage on your device. Rebind +also keeps a copy of them as a file in your Documents folder +(`Documents/Rebind/rebind-settings.json`), so that an uninstall does not lose +them. The file stays on your device. Your settings leave the device only +through Android's own backup, if you have it on, or if you share the file. **Voice typing.** If you bind the Voice typing action, Rebind asks for the microphone permission. A speech recognition app on your device, which you