Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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<String>? = null
var protectedPreferenceLines: List<String>? = null

// Targeted deletion based on selected categories
if (selectedCategories.contains(BackupCategory.LAYOUTS)) {
Expand Down Expand Up @@ -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)) {
Expand Down Expand Up @@ -413,6 +396,35 @@ private fun restoreLauncher(
}
}

internal fun restoreMainPreferences(
context: Context,
preferenceLines: List<String>?,
protectedPreferenceLines: List<String>?,
selectedCategories: Set<BackupCategory>
) {
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<String?, Any?>, out: OutputStream) {
val booleans = settings.filter { it.key is String && it.value is Boolean } as Map<String, Boolean>
Expand Down Expand Up @@ -440,6 +452,7 @@ private fun readJsonLinesToSettings(list: List<String>, 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<Map<String, Boolean>>(i.next())
Expand All @@ -462,8 +475,7 @@ private fun readJsonLinesToSettings(list: List<String>, prefs: SharedPreferences
.forEach { e.putStringSet(it.key, it.value) }
}
}
e.commit()
return true
return e.commit()
} catch (e: Exception) {
return false
}
Expand Down
151 changes: 151 additions & 0 deletions app/src/test/java/helium314/keyboard/settings/BackupPreferencesTest.kt
Original file line number Diff line number Diff line change
@@ -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>()
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<IllegalStateException> {
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<String, String>) = listOf("string settings", Json.encodeToString(strings))
}
Loading