diff --git a/app/src/main/java/helium314/keyboard/settings/preferences/BackupRestorePreference.kt b/app/src/main/java/helium314/keyboard/settings/preferences/BackupRestorePreference.kt index a895ce133..d4e1418c3 100644 --- a/app/src/main/java/helium314/keyboard/settings/preferences/BackupRestorePreference.kt +++ b/app/src/main/java/helium314/keyboard/settings/preferences/BackupRestorePreference.kt @@ -2,7 +2,9 @@ package helium314.keyboard.settings.preferences import android.content.Intent +import android.content.Context import android.content.SharedPreferences +import android.os.Build import android.os.Handler import android.os.Looper import android.widget.Toast @@ -61,7 +63,6 @@ import java.util.concurrent.TimeUnit import java.util.zip.ZipEntry import java.util.zip.ZipInputStream import java.util.zip.ZipOutputStream -import androidx.core.content.edit import helium314.keyboard.settings.FeedbackManager @Composable @@ -287,6 +288,8 @@ private fun restoreLauncher( var entry: ZipEntry? = zip.nextEntry val filesDir = ctx.filesDir ?: return@execute val deviceProtectedFilesDir = DeviceProtectedUtils.getFilesDir(ctx) + var preferenceLines: List? = null + var protectedPreferenceLines: List? = null // Targeted deletion based on selected categories if (selectedCategories.contains(BackupCategory.LAYOUTS)) { @@ -340,47 +343,27 @@ private fun restoreLauncher( FileUtils.copyStreamToNewFile(zip, restoredDb) } } else if (entry.name == PREFS_FILE_NAME) { - val prefLines = String(zip.readBytes()).split("\n") - val prefs = ctx.prefs() - prefs.edit(commit = true) { - prefs.all.keys.forEach { key -> - if (selectedCategories.contains(getCategoryForPrefKey(key))) { - remove(key) - } - } - } - readJsonLinesToSettings(prefLines, prefs, selectedCategories) + preferenceLines = String(zip.readBytes()).split("\n") } else if (entry.name == PROTECTED_PREFS_FILE_NAME) { - val prefLines = String(zip.readBytes()).split("\n") - val protectedPrefs = ctx.protectedPrefs() - protectedPrefs.edit(commit = true) { - protectedPrefs.all.keys.forEach { key -> - if (selectedCategories.contains(getCategoryForPrefKey(key))) { - remove(key) - } - } - } - readJsonLinesToSettings(prefLines, protectedPrefs, selectedCategories) + protectedPreferenceLines = String(zip.readBytes()).split("\n") } else { val auxPrefs = auxiliaryPrefsToBackUp(ctx)[entry.name] if (auxPrefs != null) { val cat = getCategoryForFilePath(entry.name) if (cat == null || selectedCategories.contains(cat)) { val prefLines = String(zip.readBytes()).split("\n") - auxPrefs.edit(commit = true) { - auxPrefs.all.keys.forEach { key -> - if (selectedCategories.contains(getCategoryForPrefKey(key))) { - remove(key) - } - } + check(readJsonLinesToSettings(prefLines, auxPrefs, selectedCategories)) { + "Could not restore preferences from ${entry.name}" } - readJsonLinesToSettings(prefLines, auxPrefs, selectedCategories) } } } zip.closeEntry() entry = zip.nextEntry } + restoreMainPreferences( + ctx, preferenceLines, protectedPreferenceLines, selectedCategories + ) } } if (selectedCategories.contains(BackupCategory.CLIPBOARD)) { @@ -413,6 +396,35 @@ private fun restoreLauncher( } } +internal fun restoreMainPreferences( + context: Context, + preferenceLines: List?, + protectedPreferenceLines: List?, + selectedCategories: Set +) { + val prefs = context.prefs() + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N || context.isDeviceProtectedStorage) { + if (preferenceLines == null && protectedPreferenceLines == null) return + // Both accessors use the same store. Clear once, retaining main-preference priority + // over legacy protected values regardless of ZIP entry order. + val combined = protectedPreferenceLines.orEmpty() + preferenceLines.orEmpty() + check(readJsonLinesToSettings(combined, prefs, selectedCategories)) { + "Could not restore preferences" + } + return + } + if (preferenceLines != null) { + check(readJsonLinesToSettings(preferenceLines, prefs, selectedCategories)) { + "Could not restore preferences" + } + } + if (protectedPreferenceLines != null) { + check(readJsonLinesToSettings(protectedPreferenceLines, context.protectedPrefs(), selectedCategories)) { + "Could not restore protected preferences" + } + } +} + @Suppress("UNCHECKED_CAST") // it is checked... but whatever (except string set, because can't check for that)) private fun settingsToJsonStream(settings: Map, out: OutputStream) { val booleans = settings.filter { it.key is String && it.value is Boolean } as Map @@ -440,6 +452,7 @@ private fun readJsonLinesToSettings(list: List, prefs: SharedPreferences val i = list.iterator() val e = prefs.edit() try { + prefs.all.keys.filter { selectedCategories.contains(getCategoryForPrefKey(it)) }.forEach { e.remove(it) } while (i.hasNext()) { when (i.next()) { "boolean settings" -> Json.decodeFromString>(i.next()) @@ -462,8 +475,7 @@ private fun readJsonLinesToSettings(list: List, prefs: SharedPreferences .forEach { e.putStringSet(it.key, it.value) } } } - e.commit() - return true + return e.commit() } catch (e: Exception) { return false } diff --git a/app/src/test/java/helium314/keyboard/settings/BackupPreferencesTest.kt b/app/src/test/java/helium314/keyboard/settings/BackupPreferencesTest.kt new file mode 100644 index 000000000..7da649305 --- /dev/null +++ b/app/src/test/java/helium314/keyboard/settings/BackupPreferencesTest.kt @@ -0,0 +1,151 @@ +// SPDX-License-Identifier: GPL-3.0-only +package helium314.keyboard.settings + +import android.content.Context +import android.content.SharedPreferences +import android.os.Build +import androidx.test.core.app.ApplicationProvider +import helium314.keyboard.latin.utils.TextExpanderUtils +import helium314.keyboard.latin.utils.DeviceProtectedUtils +import helium314.keyboard.latin.utils.prefs +import helium314.keyboard.latin.utils.protectedPrefs +import helium314.keyboard.settings.preferences.BackupCategory +import helium314.keyboard.settings.preferences.restoreMainPreferences +import kotlinx.serialization.json.Json +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertFailsWith +import kotlin.test.assertTrue + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [33]) +class BackupPreferencesTest { + private lateinit var context: Context + private lateinit var credentialContext: Context + private lateinit var prefs: SharedPreferences + private val allCategories = BackupCategory.entries.toSet() + private val shortcuts = mapOf(";greet" to TextExpanderUtils.ShortcutEntry("Hello\n\"reader\"", ";")) + + @Before + fun setup() { + credentialContext = ApplicationProvider.getApplicationContext() + context = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) + credentialContext.createDeviceProtectedStorageContext() else credentialContext + // Bind the process-wide cache to this test's storage context, not application setup's. + DeviceProtectedUtils::class.java.getDeclaredField("prefs").apply { isAccessible = true }.set(null, null) + prefs = context.prefs() + assertTrue(prefs.edit().clear().commit()) + assertTrue(credentialContext.protectedPrefs().edit().clear().commit()) + TextExpanderUtils.clearCache() + } + + @Test + fun emptyProtectedArchiveDoesNotEraseRestoredTextExpander() { + TextExpanderUtils.saveShortcuts(context, shortcuts) + val saved = requireNotNull(prefs.getString(TextExpanderUtils.PREF_DATA, null)) + // Both accessors resolve to the same file when the activity's default storage is device-protected. + val protectedPrefs = context.protectedPrefs() + assertEquals(saved, protectedPrefs.getString(TextExpanderUtils.PREF_DATA, null)) + assertTrue(prefs.edit().clear().commit()) + assertTrue(TextExpanderUtils.getShortcuts(context).isEmpty()) + restoreMainPreferences(context, lines(mapOf(TextExpanderUtils.PREF_DATA to saved)), + lines(emptyMap()), allCategories) + assertEquals(shortcuts, TextExpanderUtils.getShortcuts(context)) + } + + @Test + fun separateStoresKeepSeparateArchives() { + val other = credentialContext.protectedPrefs() + assertFalse(credentialContext.isDeviceProtectedStorage) + restoreMainPreferences(credentialContext, lines(mapOf("main" to "one")), + lines(mapOf("protected" to "two")), allCategories) + assertEquals(mapOf("main" to "one"), prefs.all) + assertEquals(mapOf("protected" to "two"), other.all) + } + + @Test + fun absentArchiveDoesNotClearItsStore() { + val other = credentialContext.protectedPrefs() + assertTrue(other.edit().clear().putString("untouched", "keep").commit()) + restoreMainPreferences(credentialContext, lines(mapOf("main" to "one")), null, allCategories) + assertEquals("keep", other.getString("untouched", null)) + } + + @Test + fun categoryFilteringIsPreserved() { + assertTrue(prefs.edit().putString("theme_style", "keep").putString("pref_text_expander_data", "old").commit()) + restoreMainPreferences(context, + lines(mapOf("pref_text_expander_data" to "{}")), lines(emptyMap()), + setOf(BackupCategory.DICTIONARY_HISTORY)) + assertEquals("keep", prefs.getString("theme_style", null)) + assertEquals("{}", prefs.getString(TextExpanderUtils.PREF_DATA, null)) + } + + @Test + fun generalSettingsDoNotRestoreTextExpander() { + restoreMainPreferences(context, lines(mapOf(TextExpanderUtils.PREF_DATA to "{}")), + null, setOf(BackupCategory.GENERAL_SETTINGS)) + assertFalse(prefs.contains(TextExpanderUtils.PREF_DATA)) + } + + @Test + fun sharedStoreRetainsBothArchivesWithMainValuesTakingPriority() { + assertTrue(prefs.edit().putString("old", "remove").commit()) + restoreMainPreferences(context, lines(mapOf("common" to "main", "mainOnly" to "one")), + lines(mapOf("common" to "legacy", "protectedOnly" to "two")), allCategories) + assertEquals(mapOf("common" to "main", "mainOnly" to "one", "protectedOnly" to "two"), prefs.all) + } + + @Test + fun sharedStoreAcceptsProtectedOnlyBackup() { + restoreMainPreferences(context, null, lines(mapOf("legacy" to "keep")), allCategories) + assertEquals(mapOf("legacy" to "keep"), prefs.all) + } + + @Test + fun missingBothArchivesLeavesPreferencesUntouched() { + assertTrue(prefs.edit().putString("old", "keep").commit()) + restoreMainPreferences(context, null, null, allCategories) + assertEquals(mapOf("old" to "keep"), prefs.all) + } + + @Test + fun malformedArchiveReportsFailureWithoutClearingStore() { + assertTrue(prefs.edit().putString("old", "keep").commit()) + assertFailsWith { + restoreMainPreferences(context, listOf("string settings", "{broken"), + lines(mapOf("protectedOnly" to "two")), allCategories) + } + assertEquals(mapOf("old" to "keep"), prefs.all) + } + + @Test + @Config(sdk = [23]) + fun preDirectBootDeviceAlsoPreservesBothArchives() { + restoreMainPreferences(context, lines(mapOf("main" to "one")), + lines(mapOf("protected" to "two")), allCategories) + assertEquals(mapOf("main" to "one", "protected" to "two"), prefs.all) + } + + @Test + fun sharedStoreRestoresEveryPreferenceType() { + val data = listOf( + "boolean settings", Json.encodeToString(mapOf("enabled" to true)), + "int settings", Json.encodeToString(mapOf("count" to 2)), + "long settings", Json.encodeToString(mapOf("time" to 3L)), + "float settings", Json.encodeToString(mapOf("scale" to 0.5f)), + "string settings", Json.encodeToString(mapOf("text" to "line\n\"quote\"")), + "string set settings", Json.encodeToString(mapOf("set" to setOf("one", "two"))) + ) + restoreMainPreferences(context, data, lines(emptyMap()), allCategories) + assertEquals(mapOf("enabled" to true, "count" to 2, "time" to 3L, "scale" to 0.5f, + "text" to "line\n\"quote\"", "set" to setOf("one", "two")), prefs.all) + } + + private fun lines(strings: Map) = listOf("string settings", Json.encodeToString(strings)) +}