/* * Copyright (C) 2014 The Android Open Source Project * modified * SPDX-License-Identifier: Apache-2.0 AND GPL-3.0-only */ package helium314.keyboard.latin.settings import android.annotation.SuppressLint import android.app.Activity import android.content.Intent import android.content.SharedPreferences import android.graphics.BitmapFactory import android.net.Uri import android.os.Build import android.os.Bundle import helium314.keyboard.latin.utils.Log import androidx.activity.result.contract.ActivityResultContracts import androidx.appcompat.app.AlertDialog import androidx.preference.Preference import androidx.preference.PreferenceManager import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json import helium314.keyboard.compat.locale import helium314.keyboard.dictionarypack.DictionaryPackConstants import helium314.keyboard.latin.utils.ChecksumCalculator import helium314.keyboard.keyboard.KeyboardLayoutSet import helium314.keyboard.keyboard.KeyboardSwitcher import helium314.keyboard.latin.AudioAndHapticFeedbackManager 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.settings.SeekBarDialogPreference.ValueProxy import helium314.keyboard.latin.utils.CUSTOM_LAYOUT_PREFIX import helium314.keyboard.latin.utils.DeviceProtectedUtils import helium314.keyboard.latin.utils.JniUtils import helium314.keyboard.latin.utils.editCustomLayout import helium314.keyboard.latin.utils.infoDialog import helium314.keyboard.latin.utils.reloadEnabledSubtypes import java.io.File import java.io.FileInputStream import java.io.FileOutputStream import java.io.IOException import java.io.OutputStream import java.util.zip.ZipEntry import java.util.zip.ZipInputStream import java.util.zip.ZipOutputStream /** * "Advanced" settings sub screen. * * This settings sub screen handles the following advanced preferences. * - Key popup dismiss delay * - Keypress vibration duration * - Keypress sound volume * - Show app icon * - Improve keyboard * - Debug settings */ class AdvancedSettingsFragment : SubScreenFragment() { private val libfile by lazy { File(requireContext().filesDir.absolutePath + File.separator + JniUtils.JNI_LIB_IMPORT_FILE_NAME) } private val backupFilePatterns by lazy { listOf( "blacklists/.*\\.txt".toRegex(), "layouts/.*.(txt|json)".toRegex(), "dicts/.*/.*user\\.dict".toRegex(), "UserHistoryDictionary.*/UserHistoryDictionary.*\\.(body|header)".toRegex(), "custom_background_image.*".toRegex(), ) } // is there any way to get additional information into the ActivityResult? would remove the need for 5 times the (almost) same code private val libraryFilePicker = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { if (it.resultCode != Activity.RESULT_OK) return@registerForActivityResult val uri = it.data?.data ?: return@registerForActivityResult copyLibrary(uri) } private val backupFilePicker = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { if (it.resultCode != Activity.RESULT_OK) return@registerForActivityResult val uri = it.data?.data ?: return@registerForActivityResult backup(uri) } private val restoreFilePicker = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { if (it.resultCode != Activity.RESULT_OK) return@registerForActivityResult val uri = it.data?.data ?: return@registerForActivityResult restore(uri) } private val dayImageFilePicker = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { if (it.resultCode != Activity.RESULT_OK) return@registerForActivityResult val uri = it.data?.data ?: return@registerForActivityResult loadImage(uri, false) } private val nightImageFilePicker = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { if (it.resultCode != Activity.RESULT_OK) return@registerForActivityResult val uri = it.data?.data ?: return@registerForActivityResult loadImage(uri, true) } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setupPreferences() } private fun setupPreferences() { addPreferencesFromResource(R.xml.prefs_screen_advanced) setDebugPrefVisibility() val context = requireContext() // When we are called from the Settings application but we are not already running, some // singleton and utility classes may not have been initialized. We have to call // initialization method of these classes here. See {@link LatinIME#onCreate()}. AudioAndHapticFeedbackManager.init(context) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { removePreference(Settings.PREF_SHOW_SETUP_WIZARD_ICON) } if (BuildConfig.BUILD_TYPE == "nouserlib") { removePreference("load_gesture_library") } setupKeyLongpressTimeoutSettings() findPreference("load_gesture_library")?.setOnPreferenceClickListener { onClickLoadLibrary() } findPreference("backup_restore")?.setOnPreferenceClickListener { showBackupRestoreDialog() } findPreference("custom_background_image")?.setOnPreferenceClickListener { onClickLoadImage() } findPreference("custom_symbols_layout")?.setOnPreferenceClickListener { val layoutName = Settings.readSymbolsLayoutName(context, context.resources.configuration.locale()).takeIf { it.startsWith(CUSTOM_LAYOUT_PREFIX) } val oldLayout = if (layoutName != null) null else context.assets.open("layouts${File.separator}symbols.txt").reader().readText() editCustomLayout(layoutName ?: "${CUSTOM_LAYOUT_PREFIX}symbols.txt", context, oldLayout, true) true } findPreference("custom_shift_symbols_layout")?.setOnPreferenceClickListener { val layoutName = Settings.readShiftedSymbolsLayoutName(context).takeIf { it.startsWith(CUSTOM_LAYOUT_PREFIX) } val oldLayout = if (layoutName != null) null else context.assets.open("layouts${File.separator}symbols_shifted.txt").reader().readText() editCustomLayout(layoutName ?: "${CUSTOM_LAYOUT_PREFIX}shift_symbols.txt", context, oldLayout, true) true } } override fun onStart() { super.onStart() // Remove debug preference. This is already done in onCreate, but if we come back from // debug prefs and have just disabled debug settings, they should disappear. setDebugPrefVisibility() } private fun setDebugPrefVisibility() { if (!BuildConfig.DEBUG && !sharedPreferences.getBoolean(DebugSettings.PREF_SHOW_DEBUG_SETTINGS, false)) { removePreference(Settings.SCREEN_DEBUG) } } @SuppressLint("ApplySharedPref") private fun onClickLoadLibrary(): Boolean { // get architecture for telling user which file to use val abi = Build.SUPPORTED_ABIS[0] // show delete / add dialog val builder = AlertDialog.Builder(requireContext()) .setTitle(R.string.load_gesture_library) .setMessage(requireContext().getString(R.string.load_gesture_library_message, abi)) .setPositiveButton(R.string.load_gesture_library_button_load) { _, _ -> val intent = Intent(Intent.ACTION_OPEN_DOCUMENT) .addCategory(Intent.CATEGORY_OPENABLE) .setType("application/octet-stream") libraryFilePicker.launch(intent) } .setNegativeButton(android.R.string.cancel, null) if (libfile.exists()) { builder.setNeutralButton(R.string.load_gesture_library_button_delete) { _, _ -> libfile.delete() PreferenceManager.getDefaultSharedPreferences(requireContext()).edit().remove(Settings.PREF_LIBRARY_CHECKSUM).commit() Runtime.getRuntime().exit(0) } } builder.show() return true } private fun copyLibrary(uri: Uri) { val tmpfile = File(requireContext().filesDir.absolutePath + File.separator + "tmplib") try { val inputStream = requireContext().contentResolver.openInputStream(uri) val outputStream = FileOutputStream(tmpfile) outputStream.use { tmpfile.setReadOnly() // as per recommendations in https://developer.android.com/about/versions/14/behavior-changes-14#safer-dynamic-code-loading FileUtils.copyStreamToOtherStream(inputStream, it) } val checksum = ChecksumCalculator.checksum(tmpfile.inputStream()) ?: "" if (checksum == JniUtils.expectedDefaultChecksum()) { renameToLibfileAndRestart(tmpfile, checksum) } else { val abi = Build.SUPPORTED_ABIS[0] AlertDialog.Builder(requireContext()) .setMessage(getString(R.string.checksum_mismatch_message, abi)) .setPositiveButton(android.R.string.ok) { _, _ -> renameToLibfileAndRestart(tmpfile, checksum) } .setNegativeButton(android.R.string.cancel) { _, _ -> tmpfile.delete() } .show() } } catch (e: IOException) { tmpfile.delete() // should inform user, but probably the issues will only come when reading the library } } private fun onClickLoadImage(): Boolean { if (Settings.readDayNightPref(sharedPreferences, resources)) { AlertDialog.Builder(requireContext()) .setMessage(R.string.day_or_night_image) .setPositiveButton(R.string.day_or_night_day) { _, _ -> customImageDialog(false) } .setNegativeButton(R.string.day_or_night_night) { _, _ -> customImageDialog(true) } .setNeutralButton(android.R.string.cancel, null) .show() } else { customImageDialog(false) } return true } private fun customImageDialog(night: Boolean) { val imageFile = Settings.getCustomBackgroundFile(requireContext(), night) val builder = AlertDialog.Builder(requireContext()) .setMessage(R.string.customize_background_image) .setPositiveButton(R.string.button_load_custom) { _, _ -> val intent = Intent(Intent.ACTION_OPEN_DOCUMENT) .addCategory(Intent.CATEGORY_OPENABLE) .setType("image/*") if (night) nightImageFilePicker.launch(intent) else dayImageFilePicker.launch(intent) } .setNegativeButton(android.R.string.cancel, null) if (imageFile.exists()) { builder.setNeutralButton(R.string.delete) { _, _ -> imageFile.delete() Settings.clearCachedBackgroundImages() KeyboardSwitcher.getInstance().forceUpdateKeyboardTheme(requireContext()) } } builder.show() } private fun loadImage(uri: Uri, night: Boolean) { val imageFile = Settings.getCustomBackgroundFile(requireContext(), night) FileUtils.copyStreamToNewFile(requireContext().contentResolver.openInputStream(uri), imageFile) try { BitmapFactory.decodeFile(imageFile.absolutePath) } catch (_: Exception) { infoDialog(requireContext(), R.string.file_read_error) imageFile.delete() } Settings.clearCachedBackgroundImages() KeyboardSwitcher.getInstance().forceUpdateKeyboardTheme(requireContext()) } @SuppressLint("ApplySharedPref") private fun renameToLibfileAndRestart(file: File, checksum: String) { libfile.delete() // store checksum in default preferences (soo JniUtils) PreferenceManager.getDefaultSharedPreferences(requireContext()).edit().putString(Settings.PREF_LIBRARY_CHECKSUM, checksum).commit() file.renameTo(libfile) Runtime.getRuntime().exit(0) // exit will restart the app, so library will be loaded } private fun showBackupRestoreDialog(): Boolean { AlertDialog.Builder(requireContext()) .setTitle(R.string.backup_restore_title) .setMessage(R.string.backup_restore_message) .setNegativeButton(R.string.button_backup) { _, _ -> val intent = Intent(Intent.ACTION_CREATE_DOCUMENT) .addCategory(Intent.CATEGORY_OPENABLE) .putExtra( Intent.EXTRA_TITLE, requireContext().getString(R.string.english_ime_name) .replace(" ", "_") + "_backup.zip" ) .setType("application/zip") backupFilePicker.launch(intent) } .setPositiveButton(android.R.string.cancel, null) .setNeutralButton(R.string.button_restore) { _, _ -> val intent = Intent(Intent.ACTION_OPEN_DOCUMENT) .addCategory(Intent.CATEGORY_OPENABLE) .setType("application/zip") restoreFilePicker.launch(intent) } .show() return true } private fun backup(uri: Uri) { // zip all files matching the backup patterns // essentially this is the typed words information, and user-added dictionaries val filesDir = requireContext().filesDir ?: return 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.getDeviceProtectedContext(requireContext()).filesDir 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) } try { activity?.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(sharedPreferences.all, zipStream) zipStream.closeEntry() zipStream.putNextEntry(ZipEntry(PROTECTED_PREFS_FILE_NAME)) settingsToJsonStream(PreferenceManager.getDefaultSharedPreferences(requireContext()).all, zipStream) zipStream.closeEntry() zipStream.close() } } catch (t: Throwable) { // inform about every error Log.w(TAG, "error during backup", t) infoDialog(requireContext(), requireContext().getString(R.string.backup_error, t.message)) } } private fun restore(uri: Uri) { try { activity?.contentResolver?.openInputStream(uri)?.use { inputStream -> ZipInputStream(inputStream).use { zip -> var entry: ZipEntry? = zip.nextEntry val filesDir = requireContext().filesDir?.path ?: return val deviceProtectedFilesDir = DeviceProtectedUtils.getDeviceProtectedContext(requireContext()).filesDir?.path ?: return 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") sharedPreferences.edit().clear().apply() readJsonLinesToSettings(prefLines, sharedPreferences) } else if (entry.name == PROTECTED_PREFS_FILE_NAME) { val prefLines = String(zip.readBytes()).split("\n") val protectedPrefs = PreferenceManager.getDefaultSharedPreferences(requireContext()) protectedPrefs.edit().clear().apply() readJsonLinesToSettings(prefLines, protectedPrefs) } zip.closeEntry() entry = zip.nextEntry } } } checkVersionUpgrade(requireContext()) reloadEnabledSubtypes(requireContext()) val newDictBroadcast = Intent(DictionaryPackConstants.NEW_DICTIONARY_INTENT_ACTION) activity?.sendBroadcast(newDictBroadcast) // reload current prefs screen preferenceScreen.removeAll() setupPreferences() KeyboardSwitcher.getInstance().forceUpdateKeyboardTheme(requireContext()) } catch (t: Throwable) { // inform about every error Log.w(TAG, "error during restore", t) infoDialog(requireContext(), requireContext().getString(R.string.restore_error, t.message)) } } // 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 symbols layout val localeString = originalName.substringAfter(".").substringBefore(".") 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 } } private fun setupKeyLongpressTimeoutSettings() { val prefs = sharedPreferences findPreference(Settings.PREF_KEY_LONGPRESS_TIMEOUT)?.setInterface(object : ValueProxy { override fun writeValue(value: Int, key: String) = prefs.edit().putInt(key, value).apply() override fun writeDefaultValue(key: String) = prefs.edit().remove(key).apply() override fun readValue(key: String) = Settings.readKeyLongpressTimeout(prefs, resources) override fun readDefaultValue(key: String) = Settings.readDefaultKeyLongpressTimeout(resources) override fun getValueText(value: Int) = resources.getString(R.string.abbreviation_unit_milliseconds, value.toString()) override fun feedbackValue(value: Int) {} }) } override fun onSharedPreferenceChanged(prefs: SharedPreferences, key: String?) { when (key) { Settings.PREF_SHOW_SETUP_WIZARD_ICON -> SystemBroadcastReceiver.toggleAppIcon(requireContext()) Settings.PREF_MORE_POPUP_KEYS -> KeyboardLayoutSet.onSystemLocaleChanged() } } companion object { @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.filterValues { it is Boolean } as Map val ints = settings.filterValues { it is Int } as Map val longs = settings.filterValues { it is Long } as Map val floats = settings.filterValues { it is Float } as Map val strings = settings.filterValues { it is String } as Map val stringSets = settings.filterValues { it 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 } } } } private const val PREFS_FILE_NAME = "preferences.json" private const val PROTECTED_PREFS_FILE_NAME = "protected_preferences.json" private const val TAG = "AdvancedSettingsFragment"