mirror of
https://github.com/Helium314/HeliBoard.git
synced 2025-05-14 14:02:44 +00:00
add backup/restore pref
This commit is contained in:
parent
22977d4a8c
commit
8f5ef56f60
2 changed files with 275 additions and 4 deletions
|
@ -15,8 +15,8 @@ import kotlinx.coroutines.flow.MutableStateFlow
|
|||
|
||||
// todo (roughly in order)
|
||||
// make all prefs actually work
|
||||
// advanced
|
||||
// preferences
|
||||
// advanced (not much)
|
||||
// try moving the recomposition of pref change somewhere else, so it's not duplicated everywhere
|
||||
// make the pref lists more compact (compare with old settings)
|
||||
// try making text size similar to old state (also in dialogs)
|
||||
|
@ -35,6 +35,7 @@ import kotlinx.coroutines.flow.MutableStateFlow
|
|||
// animations when stuff (dis)appears
|
||||
// LaunchedEffect, AnimatedVisibility
|
||||
// bg image inconsistent about being on toolbar or not
|
||||
// maybe move some large prefs out of their screens into separate files (backup/restore!)
|
||||
// performance
|
||||
// find a nice way of testing (probably add logs for measuring time and recompositions)
|
||||
// consider that stuff in composables can get called quite often on any changes
|
||||
|
@ -83,6 +84,7 @@ import kotlinx.coroutines.flow.MutableStateFlow
|
|||
// rearrange settings screens? now it should be very simple to do (definitely separate PR)
|
||||
// actually lenient json parsing is not good in a certain way: we should show an error if a json property is unknown
|
||||
// syntax highlighting for json? should show basic json errors
|
||||
// does restore prefs not delete dictionaries?
|
||||
|
||||
// preliminary results:
|
||||
// looks ok (ugly M3 switches)
|
||||
|
|
|
@ -4,6 +4,7 @@ import android.annotation.SuppressLint
|
|||
import android.app.Activity
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.SharedPreferences
|
||||
import android.os.Build
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
|
@ -22,24 +23,45 @@ import androidx.compose.ui.platform.LocalContext
|
|||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.preference.PreferenceManager
|
||||
import helium314.keyboard.dictionarypack.DictionaryPackConstants
|
||||
import helium314.keyboard.keyboard.KeyboardLayoutSet
|
||||
import helium314.keyboard.keyboard.internal.keyboard_parser.LAYOUT_NUMBER
|
||||
import helium314.keyboard.keyboard.internal.keyboard_parser.LAYOUT_NUMPAD
|
||||
import helium314.keyboard.keyboard.internal.keyboard_parser.LAYOUT_NUMPAD_LANDSCAPE
|
||||
import helium314.keyboard.keyboard.internal.keyboard_parser.LAYOUT_PHONE
|
||||
import helium314.keyboard.keyboard.internal.keyboard_parser.LAYOUT_PHONE_SYMBOLS
|
||||
import helium314.keyboard.keyboard.internal.keyboard_parser.LAYOUT_SYMBOLS
|
||||
import helium314.keyboard.keyboard.internal.keyboard_parser.LAYOUT_SYMBOLS_ARABIC
|
||||
import helium314.keyboard.keyboard.internal.keyboard_parser.LAYOUT_SYMBOLS_SHIFTED
|
||||
import helium314.keyboard.keyboard.internal.keyboard_parser.RawKeyboardParser
|
||||
import helium314.keyboard.latin.BuildConfig
|
||||
import helium314.keyboard.latin.R
|
||||
import helium314.keyboard.latin.SystemBroadcastReceiver
|
||||
import helium314.keyboard.latin.checkVersionUpgrade
|
||||
import helium314.keyboard.latin.common.FileUtils
|
||||
import helium314.keyboard.latin.common.LocaleUtils.constructLocale
|
||||
import helium314.keyboard.latin.common.splitOnWhitespace
|
||||
import helium314.keyboard.latin.settings.DebugSettings
|
||||
import helium314.keyboard.latin.settings.Settings
|
||||
import helium314.keyboard.latin.settings.USER_DICTIONARY_SUFFIX
|
||||
import helium314.keyboard.latin.utils.AdditionalSubtypeUtils
|
||||
import helium314.keyboard.latin.utils.CUSTOM_FUNCTIONAL_LAYOUT_NORMAL
|
||||
import helium314.keyboard.latin.utils.CUSTOM_FUNCTIONAL_LAYOUT_SYMBOLS
|
||||
import helium314.keyboard.latin.utils.CUSTOM_FUNCTIONAL_LAYOUT_SYMBOLS_SHIFTED
|
||||
import helium314.keyboard.latin.utils.CUSTOM_LAYOUT_PREFIX
|
||||
import helium314.keyboard.latin.utils.ChecksumCalculator
|
||||
import helium314.keyboard.latin.utils.DeviceProtectedUtils
|
||||
import helium314.keyboard.latin.utils.ExecutorUtils
|
||||
import helium314.keyboard.latin.utils.JniUtils
|
||||
import helium314.keyboard.latin.utils.Log
|
||||
import helium314.keyboard.latin.utils.getActivity
|
||||
import helium314.keyboard.latin.utils.getCustomLayoutFiles
|
||||
import helium314.keyboard.latin.utils.getStringResourceOrName
|
||||
import helium314.keyboard.latin.utils.onCustomLayoutFileListChanged
|
||||
import helium314.keyboard.latin.utils.prefs
|
||||
import helium314.keyboard.latin.utils.reloadEnabledSubtypes
|
||||
import helium314.keyboard.latin.utils.updateAdditionalSubtypes
|
||||
import helium314.keyboard.settings.AllPrefs
|
||||
import helium314.keyboard.settings.ListPreference
|
||||
import helium314.keyboard.settings.NonSettingsPrefs
|
||||
|
@ -54,13 +76,20 @@ import helium314.keyboard.settings.SwitchPreference
|
|||
import helium314.keyboard.settings.Theme
|
||||
import helium314.keyboard.settings.dialogs.ConfirmationDialog
|
||||
import helium314.keyboard.settings.dialogs.CustomizeLayoutDialog
|
||||
import helium314.keyboard.settings.dialogs.InfoDialog
|
||||
import helium314.keyboard.settings.dialogs.ListPickerDialog
|
||||
import helium314.keyboard.settings.dialogs.TextInputDialog
|
||||
import helium314.keyboard.settings.keyboardNeedsReload
|
||||
import kotlinx.serialization.json.Json
|
||||
import java.io.File
|
||||
import java.io.FileInputStream
|
||||
import java.io.FileOutputStream
|
||||
import java.io.IOException
|
||||
import java.io.OutputStream
|
||||
import java.util.concurrent.CountDownLatch
|
||||
import java.util.zip.ZipEntry
|
||||
import java.util.zip.ZipInputStream
|
||||
import java.util.zip.ZipOutputStream
|
||||
|
||||
@Composable
|
||||
fun AdvancedSettingsScreen(
|
||||
|
@ -285,13 +314,172 @@ fun createAdvancedPrefs(context: Context) = listOf(
|
|||
)
|
||||
}
|
||||
},
|
||||
PrefDef(context, NonSettingsPrefs.BACKUP_RESTORE, R.string.backup_restore_title) {
|
||||
PrefDef(context, NonSettingsPrefs.BACKUP_RESTORE, R.string.backup_restore_title) { def ->
|
||||
var showDialog by remember { mutableStateOf(false) }
|
||||
val ctx = LocalContext.current
|
||||
val prefs = ctx.prefs()
|
||||
var error: String? by remember { mutableStateOf(null) }
|
||||
val backupFilePatterns by lazy { listOf(
|
||||
"blacklists/.*\\.txt".toRegex(),
|
||||
"layouts/$CUSTOM_LAYOUT_PREFIX+\\..{0,4}".toRegex(), // can't expect a period at the end, as this would break restoring older backups
|
||||
"dicts/.*/.*user\\.dict".toRegex(),
|
||||
"UserHistoryDictionary.*/UserHistoryDictionary.*\\.(body|header)".toRegex(),
|
||||
"custom_background_image.*".toRegex(),
|
||||
"custom_font".toRegex(),
|
||||
) }
|
||||
val backupLauncher = rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) {
|
||||
if (it.resultCode != Activity.RESULT_OK) return@rememberLauncherForActivityResult
|
||||
val uri = it.data?.data ?: return@rememberLauncherForActivityResult
|
||||
// zip all files matching the backup patterns
|
||||
// essentially this is the typed words information, and user-added dictionaries
|
||||
val filesDir = ctx.filesDir ?: return@rememberLauncherForActivityResult
|
||||
val filesPath = filesDir.path + File.separator
|
||||
val files = mutableListOf<File>()
|
||||
filesDir.walk().forEach { file ->
|
||||
val path = file.path.replace(filesPath, "")
|
||||
if (backupFilePatterns.any { path.matches(it) })
|
||||
files.add(file)
|
||||
}
|
||||
val protectedFilesDir = DeviceProtectedUtils.getFilesDir(ctx)
|
||||
val protectedFilesPath = protectedFilesDir.path + File.separator
|
||||
val protectedFiles = mutableListOf<File>()
|
||||
protectedFilesDir.walk().forEach { file ->
|
||||
val path = file.path.replace(protectedFilesPath, "")
|
||||
if (backupFilePatterns.any { path.matches(it) })
|
||||
protectedFiles.add(file)
|
||||
}
|
||||
val wait = CountDownLatch(1)
|
||||
ExecutorUtils.getBackgroundExecutor(ExecutorUtils.KEYBOARD).execute {
|
||||
try {
|
||||
ctx.getActivity()?.contentResolver?.openOutputStream(uri)?.use { os ->
|
||||
// write files to zip
|
||||
val zipStream = ZipOutputStream(os)
|
||||
files.forEach {
|
||||
val fileStream = FileInputStream(it).buffered()
|
||||
zipStream.putNextEntry(ZipEntry(it.path.replace(filesPath, "")))
|
||||
fileStream.copyTo(zipStream, 1024)
|
||||
fileStream.close()
|
||||
zipStream.closeEntry()
|
||||
}
|
||||
protectedFiles.forEach {
|
||||
val fileStream = FileInputStream(it).buffered()
|
||||
zipStream.putNextEntry(ZipEntry(it.path.replace(protectedFilesDir.path, "unprotected")))
|
||||
fileStream.copyTo(zipStream, 1024)
|
||||
fileStream.close()
|
||||
zipStream.closeEntry()
|
||||
}
|
||||
zipStream.putNextEntry(ZipEntry(PREFS_FILE_NAME))
|
||||
settingsToJsonStream(prefs.all, zipStream)
|
||||
zipStream.closeEntry()
|
||||
zipStream.putNextEntry(ZipEntry(PROTECTED_PREFS_FILE_NAME))
|
||||
settingsToJsonStream(PreferenceManager.getDefaultSharedPreferences(ctx).all, zipStream)
|
||||
zipStream.closeEntry()
|
||||
zipStream.close()
|
||||
}
|
||||
} catch (t: Throwable) {
|
||||
error = "b" + t.message
|
||||
Log.w("AdvancedScreen", "error during backup", t)
|
||||
} finally {
|
||||
wait.countDown()
|
||||
}
|
||||
}
|
||||
wait.await()
|
||||
}
|
||||
val restoreLauncher = rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) {
|
||||
if (it.resultCode != Activity.RESULT_OK) return@rememberLauncherForActivityResult
|
||||
val uri = it.data?.data ?: return@rememberLauncherForActivityResult
|
||||
val wait = CountDownLatch(1)
|
||||
ExecutorUtils.getBackgroundExecutor(ExecutorUtils.KEYBOARD).execute {
|
||||
try {
|
||||
ctx.getActivity()?.contentResolver?.openInputStream(uri)?.use { inputStream ->
|
||||
ZipInputStream(inputStream).use { zip ->
|
||||
var entry: ZipEntry? = zip.nextEntry
|
||||
val filesDir = ctx.filesDir?.path ?: return@execute
|
||||
val deviceProtectedFilesDir = DeviceProtectedUtils.getFilesDir(ctx).path
|
||||
Settings.getInstance().stopListener()
|
||||
while (entry != null) {
|
||||
if (entry.name.startsWith("unprotected${File.separator}")) {
|
||||
val adjustedName = entry.name.substringAfter("unprotected${File.separator}")
|
||||
if (backupFilePatterns.any { adjustedName.matches(it) }) {
|
||||
val targetFileName = upgradeFileNames(adjustedName)
|
||||
val file = File(deviceProtectedFilesDir, targetFileName)
|
||||
FileUtils.copyStreamToNewFile(zip, file)
|
||||
}
|
||||
} else if (backupFilePatterns.any { entry!!.name.matches(it) }) {
|
||||
val targetFileName = upgradeFileNames(entry.name)
|
||||
val file = File(filesDir, targetFileName)
|
||||
FileUtils.copyStreamToNewFile(zip, file)
|
||||
} else if (entry.name == PREFS_FILE_NAME) {
|
||||
val prefLines = String(zip.readBytes()).split("\n")
|
||||
prefs.edit().clear().apply()
|
||||
readJsonLinesToSettings(prefLines, prefs)
|
||||
} else if (entry.name == PROTECTED_PREFS_FILE_NAME) {
|
||||
val prefLines = String(zip.readBytes()).split("\n")
|
||||
val protectedPrefs = PreferenceManager.getDefaultSharedPreferences(ctx)
|
||||
protectedPrefs.edit().clear().apply()
|
||||
readJsonLinesToSettings(prefLines, protectedPrefs)
|
||||
}
|
||||
zip.closeEntry()
|
||||
entry = zip.nextEntry
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (t: Throwable) {
|
||||
error = "r" + t.message
|
||||
Log.w("AdvancedScreen", "error during restore", t)
|
||||
} finally {
|
||||
wait.countDown()
|
||||
}
|
||||
}
|
||||
wait.await()
|
||||
checkVersionUpgrade(ctx)
|
||||
Settings.getInstance().startListener()
|
||||
val additionalSubtypes = Settings.readPrefAdditionalSubtypes(prefs, ctx.resources)
|
||||
updateAdditionalSubtypes(AdditionalSubtypeUtils.createAdditionalSubtypesArray(additionalSubtypes))
|
||||
reloadEnabledSubtypes(ctx)
|
||||
val newDictBroadcast = Intent(DictionaryPackConstants.NEW_DICTIONARY_INTENT_ACTION)
|
||||
ctx.getActivity()?.sendBroadcast(newDictBroadcast)
|
||||
onCustomLayoutFileListChanged()
|
||||
(ctx.getActivity() as? SettingsActivity2)?.prefChanged?.value = 210 // for settings reload
|
||||
keyboardNeedsReload = true
|
||||
}
|
||||
Preference(
|
||||
name = it.title,
|
||||
name = def.title,
|
||||
onClick = { showDialog = true }
|
||||
)
|
||||
// if (showDialog) todo: show the dialog
|
||||
if (showDialog) {
|
||||
ConfirmationDialog(
|
||||
onDismissRequest = { showDialog = false },
|
||||
title = { Text(stringResource(R.string.backup_restore_title)) },
|
||||
text = { Text(stringResource(R.string.backup_restore_message)) },
|
||||
confirmButtonText = stringResource(R.string.button_backup),
|
||||
neutralButtonText = stringResource(R.string.button_restore),
|
||||
onNeutral = {
|
||||
val intent = Intent(Intent.ACTION_OPEN_DOCUMENT)
|
||||
.addCategory(Intent.CATEGORY_OPENABLE)
|
||||
.setType("application/zip")
|
||||
restoreLauncher.launch(intent)
|
||||
},
|
||||
onConfirmed = {
|
||||
val intent = Intent(Intent.ACTION_CREATE_DOCUMENT)
|
||||
.addCategory(Intent.CATEGORY_OPENABLE)
|
||||
.putExtra(
|
||||
Intent.EXTRA_TITLE,
|
||||
ctx.getString(R.string.english_ime_name)
|
||||
.replace(" ", "_") + "_backup.zip"
|
||||
)
|
||||
.setType("application/zip")
|
||||
backupLauncher.launch(intent)
|
||||
}
|
||||
)
|
||||
}
|
||||
if (error != null) {
|
||||
InfoDialog(
|
||||
if (error!!.startsWith("b"))
|
||||
stringResource(R.string.backup_error, error!!.drop(1))
|
||||
else stringResource(R.string.restore_error, error!!.drop(1))
|
||||
) { error = null }
|
||||
}
|
||||
},
|
||||
PrefDef(context, NonSettingsPrefs.DEBUG_SETTINGS, R.string.debug_settings_title) {
|
||||
Preference(
|
||||
|
@ -433,3 +621,84 @@ private fun Preview() {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
// stuff for backup / restore
|
||||
private const val PREFS_FILE_NAME = "preferences.json"
|
||||
private const val PROTECTED_PREFS_FILE_NAME = "protected_preferences.json"
|
||||
|
||||
@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>
|
||||
val ints = settings.filter { it.key is String && it.value is Int } as Map<String, Int>
|
||||
val longs = settings.filter { it.key is String && it.value is Long } as Map<String, Long>
|
||||
val floats = settings.filter { it.key is String && it.value is Float } as Map<String, Float>
|
||||
val strings = settings.filter { it.key is String && it.value is String } as Map<String, String>
|
||||
val stringSets = settings.filter { it.key is String && it.value is Set<*> } as Map<String, Set<String>>
|
||||
// now write
|
||||
out.write("boolean settings\n".toByteArray())
|
||||
out.write(Json.encodeToString(booleans).toByteArray())
|
||||
out.write("\nint settings\n".toByteArray())
|
||||
out.write(Json.encodeToString(ints).toByteArray())
|
||||
out.write("\nlong settings\n".toByteArray())
|
||||
out.write(Json.encodeToString(longs).toByteArray())
|
||||
out.write("\nfloat settings\n".toByteArray())
|
||||
out.write(Json.encodeToString(floats).toByteArray())
|
||||
out.write("\nstring settings\n".toByteArray())
|
||||
out.write(Json.encodeToString(strings).toByteArray())
|
||||
out.write("\nstring set settings\n".toByteArray())
|
||||
out.write(Json.encodeToString(stringSets).toByteArray())
|
||||
}
|
||||
|
||||
private fun readJsonLinesToSettings(list: List<String>, prefs: SharedPreferences): Boolean {
|
||||
val i = list.iterator()
|
||||
val e = prefs.edit()
|
||||
try {
|
||||
while (i.hasNext()) {
|
||||
when (i.next()) {
|
||||
"boolean settings" -> Json.decodeFromString<Map<String, Boolean>>(i.next()).forEach { e.putBoolean(it.key, it.value) }
|
||||
"int settings" -> Json.decodeFromString<Map<String, Int>>(i.next()).forEach { e.putInt(it.key, it.value) }
|
||||
"long settings" -> Json.decodeFromString<Map<String, Long>>(i.next()).forEach { e.putLong(it.key, it.value) }
|
||||
"float settings" -> Json.decodeFromString<Map<String, Float>>(i.next()).forEach { e.putFloat(it.key, it.value) }
|
||||
"string settings" -> Json.decodeFromString<Map<String, String>>(i.next()).forEach { e.putString(it.key, it.value) }
|
||||
"string set settings" -> Json.decodeFromString<Map<String, Set<String>>>(i.next()).forEach { e.putStringSet(it.key, it.value) }
|
||||
}
|
||||
}
|
||||
e.apply()
|
||||
return true
|
||||
} catch (e: Exception) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// todo (later): remove this when new package name has been in use for long enough, this is only for migrating from old openboard name
|
||||
private fun upgradeFileNames(originalName: String): String {
|
||||
return when {
|
||||
originalName.endsWith(USER_DICTIONARY_SUFFIX) -> {
|
||||
// replace directory after switch to language tag
|
||||
val dirName = originalName.substringAfter(File.separator).substringBefore(File.separator)
|
||||
originalName.replace(dirName, dirName.constructLocale().toLanguageTag())
|
||||
}
|
||||
originalName.startsWith("blacklists") -> {
|
||||
// replace file name after switch to language tag
|
||||
val fileName = originalName.substringAfter("blacklists${File.separator}").substringBefore(".txt")
|
||||
originalName.replace(fileName, fileName.constructLocale().toLanguageTag())
|
||||
}
|
||||
originalName.startsWith("layouts") -> {
|
||||
// replace file name after switch to language tag, but only if it's not a layout
|
||||
val localeString = originalName.substringAfter(".").substringBefore(".")
|
||||
if (localeString in listOf(LAYOUT_SYMBOLS, LAYOUT_SYMBOLS_SHIFTED, LAYOUT_SYMBOLS_ARABIC, LAYOUT_NUMBER, LAYOUT_NUMPAD, LAYOUT_NUMPAD_LANDSCAPE, LAYOUT_PHONE, LAYOUT_PHONE_SYMBOLS))
|
||||
return originalName // it's a layout!
|
||||
val locale = localeString.constructLocale()
|
||||
if (locale.toLanguageTag() != "und")
|
||||
originalName.replace(localeString, locale.toLanguageTag())
|
||||
else
|
||||
originalName // no valid locale -> must be symbols layout, don't change
|
||||
}
|
||||
originalName.startsWith("UserHistoryDictionary") -> {
|
||||
val localeString = originalName.substringAfter(".").substringBefore(".")
|
||||
val locale = localeString.constructLocale()
|
||||
originalName.replace(localeString, locale.toLanguageTag())
|
||||
}
|
||||
else -> originalName
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue