diff --git a/app/src/main/java/helium314/keyboard/settings/SettingsActivity.kt b/app/src/main/java/helium314/keyboard/settings/SettingsActivity.kt index 2842dfe7c..831ed0c29 100644 --- a/app/src/main/java/helium314/keyboard/settings/SettingsActivity.kt +++ b/app/src/main/java/helium314/keyboard/settings/SettingsActivity.kt @@ -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) diff --git a/app/src/main/java/helium314/keyboard/settings/screens/AdvancedScreen.kt b/app/src/main/java/helium314/keyboard/settings/screens/AdvancedScreen.kt index 7f6b020cb..3c7a50402 100644 --- a/app/src/main/java/helium314/keyboard/settings/screens/AdvancedScreen.kt +++ b/app/src/main/java/helium314/keyboard/settings/screens/AdvancedScreen.kt @@ -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() + 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() + 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, out: OutputStream) { + val booleans = settings.filter { it.key is String && it.value is Boolean } as Map + val ints = settings.filter { it.key is String && it.value is Int } as Map + val longs = settings.filter { it.key is String && it.value is Long } as Map + val floats = settings.filter { it.key is String && it.value is Float } as Map + val strings = settings.filter { it.key is String && it.value is String } as Map + val stringSets = settings.filter { it.key is String && it.value is Set<*> } as Map> + // 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, prefs: SharedPreferences): Boolean { + val i = list.iterator() + val e = prefs.edit() + try { + while (i.hasNext()) { + when (i.next()) { + "boolean settings" -> Json.decodeFromString>(i.next()).forEach { e.putBoolean(it.key, it.value) } + "int settings" -> Json.decodeFromString>(i.next()).forEach { e.putInt(it.key, it.value) } + "long settings" -> Json.decodeFromString>(i.next()).forEach { e.putLong(it.key, it.value) } + "float settings" -> Json.decodeFromString>(i.next()).forEach { e.putFloat(it.key, it.value) } + "string settings" -> Json.decodeFromString>(i.next()).forEach { e.putString(it.key, it.value) } + "string set settings" -> Json.decodeFromString>>(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 + } +}