move large preferences to separate files

This commit is contained in:
Helium314 2025-02-07 06:18:27 +01:00
parent 0f4ac58582
commit b62eaba3e4
21 changed files with 1147 additions and 1010 deletions

View file

@ -1,392 +0,0 @@
// SPDX-License-Identifier: GPL-3.0-only
package helium314.keyboard.settings
import android.content.SharedPreferences
import androidx.annotation.DrawableRes
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.Hyphens
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.core.content.edit
import helium314.keyboard.keyboard.internal.KeyboardIconsSet
import helium314.keyboard.latin.R
import helium314.keyboard.latin.utils.Log
import helium314.keyboard.latin.utils.getActivity
import helium314.keyboard.latin.utils.getStringResourceOrName
import helium314.keyboard.latin.utils.prefs
import helium314.keyboard.settings.dialogs.ListPickerDialog
import helium314.keyboard.settings.dialogs.ReorderDialog
import helium314.keyboard.settings.dialogs.SliderDialog
import helium314.keyboard.settings.screens.GetIcon
import kotlin.math.roundToInt
// partially taken from StreetComplete / SCEE
@Composable
fun PreferenceCategory(
title: String,
modifier: Modifier = Modifier,
) {
Column {
HorizontalDivider()
Text(
text = title,
modifier = modifier.padding(top = 12.dp, start = 16.dp, end = 8.dp, bottom = 8.dp),
color = MaterialTheme.colorScheme.secondary,
style = MaterialTheme.typography.titleSmall
)
}
}
@Composable
fun Preference(
name: String,
onClick: () -> Unit,
modifier: Modifier = Modifier,
description: String? = null,
@DrawableRes icon: Int? = null,
value: @Composable (RowScope.() -> Unit)? = null,
) {
Row(
modifier = modifier
.fillMaxWidth()
.clickable { onClick() }
.heightIn(min = 44.dp)
.padding(12.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.CenterVertically
) {
if (icon != null)
Icon(painterResource(icon), name, modifier = Modifier.size(36.dp))
Column(modifier = Modifier.weight(1f)) {
Text(text = name, style = MaterialTheme.typography.bodyLarge)
if (description != null) {
CompositionLocalProvider(
LocalTextStyle provides MaterialTheme.typography.bodyMedium,
LocalContentColor provides MaterialTheme.colorScheme.onSurfaceVariant
) {
Text(
text = description,
modifier = Modifier.padding(top = 2.dp)
)
}
}
}
if (value != null) {
CompositionLocalProvider(
LocalTextStyle provides LocalTextStyle.current.copy(
textAlign = TextAlign.End,
hyphens = Hyphens.Auto
),
LocalContentColor provides MaterialTheme.colorScheme.onSurfaceVariant
) {
Row(
horizontalArrangement = Arrangement.spacedBy(
space = 8.dp,
alignment = Alignment.End
),
verticalAlignment = Alignment.CenterVertically,
) { value() }
}
}
}
}
@Composable
fun SwitchPreference(
name: String,
modifier: Modifier = Modifier,
pref: String,
default: Boolean,
description: String? = null,
allowCheckedChange: (Boolean) -> Boolean = { true }, // true means ok, usually for showing some dialog
onCheckedChange: (Boolean) -> Unit = { },
) {
val ctx = LocalContext.current
val prefs = ctx.prefs()
val b = (ctx.getActivity() as? SettingsActivity2)?.prefChanged?.collectAsState()
if ((b?.value ?: 0) < 0)
Log.v("irrelevant", "stupid way to trigger recomposition on preference change")
var value = prefs.getBoolean(pref, default)
fun switched(newValue: Boolean) {
if (!allowCheckedChange(newValue)) {
value = !newValue
return
}
value = newValue
prefs.edit().putBoolean(pref, newValue).apply()
onCheckedChange(newValue)
}
Preference(
name = name,
onClick = { switched(!value) },
modifier = modifier,
description = description
) {
Switch(
checked = value,
onCheckedChange = { switched(it) },
// switch is really ugly... how
// colors = SwitchDefaults.colors(uncheckedBorderColor = Color.Transparent)
)
}
}
@Composable
fun SwitchPreference(
def: PrefDef,
default: Boolean,
allowCheckedChange: (Boolean) -> Boolean = { true },
onCheckedChange: (Boolean) -> Unit = { }
) {
SwitchPreference(
name = def.title,
description = def.description,
pref = def.key,
default = default,
allowCheckedChange = allowCheckedChange,
onCheckedChange = onCheckedChange
)
}
@Composable
/** Slider preference for Int or Float (weird casting stuff, but should be fine) */
fun <T: Number> SliderPreference(
name: String,
modifier: Modifier = Modifier,
pref: String,
description: @Composable (T) -> String,
default: T,
range: ClosedFloatingPointRange<Float>,
stepSize: Int? = null,
onValueChanged: (Float) -> Unit = { },
) {
val ctx = LocalContext.current
val prefs = ctx.prefs()
val b = (ctx.getActivity() as? SettingsActivity2)?.prefChanged?.collectAsState()
if ((b?.value ?: 0) < 0)
Log.v("irrelevant", "stupid way to trigger recomposition on preference change")
val initialValue = if (default is Int || default is Float)
getPrefOfType(prefs, pref, default)
else throw IllegalArgumentException("only float and int are supported")
var showDialog by remember { mutableStateOf(false) }
Preference(
name = name,
onClick = { showDialog = true },
modifier = modifier,
description = description(initialValue)
)
if (showDialog)
SliderDialog(
onDismissRequest = { showDialog = false },
onDone = {
if (default is Int) prefs.edit().putInt(pref, it.toInt()).apply()
else prefs.edit().putFloat(pref, it).apply()
},
initialValue = initialValue.toFloat(),
range = range,
positionString = {
@Suppress("UNCHECKED_CAST")
description((if (default is Int) it.roundToInt() else it) as T)
},
onValueChanged = onValueChanged,
showDefault = true,
onDefault = { prefs.edit().remove(pref).apply() },
intermediateSteps = stepSize?.let {
// this is not nice, but slider wants it like this...
((range.endInclusive - range.start) / it - 1).toInt()
}
)
}
@Composable
fun <T: Any> ListPreference(
def: PrefDef,
items: List<Pair<String, T>>,
default: T,
onChanged: (T) -> Unit = { }
) {
var showDialog by remember { mutableStateOf(false) }
val prefs = LocalContext.current.prefs()
val selected = items.firstOrNull { it.second == getPrefOfType(prefs, def.key, default) }
Preference(
name = def.title,
description = selected?.first,
onClick = { showDialog = true }
)
if (showDialog) {
ListPickerDialog(
onDismissRequest = { showDialog = false },
items = items,
onItemSelected = {
if (it == selected) return@ListPickerDialog
putPrefOfType(prefs, def.key, it.second)
onChanged(it.second)
},
selectedItem = selected,
title = { Text(def.title) },
getItemName = { it.first }
)
}
}
@Composable
fun ReorderSwitchPreference(def: PrefDef, default: String) {
var showDialog by remember { mutableStateOf(false) }
Preference(
name = def.title,
description = def.description,
onClick = { showDialog = true },
)
if (showDialog) {
val ctx = LocalContext.current
val prefs = ctx.prefs()
val items = prefs.getString(def.key, default)!!.split(";").mapTo(ArrayList()) {
val both = it.split(",")
KeyAndState(both.first(), both.last().toBoolean())
}
ReorderDialog(
onConfirmed = { reorderedItems ->
val value = reorderedItems.joinToString(";") { it.name + "," + it.state }
prefs.edit().putString(def.key, value).apply()
keyboardNeedsReload = true
},
onDismissRequest = { showDialog = false },
onNeutral = { prefs.edit().remove(def.key).apply() },
neutralButtonText = if (prefs.contains(def.key)) stringResource(R.string.button_default) else null,
items = items,
title = { Text(def.title) },
displayItem = { item ->
var checked by remember { mutableStateOf(item.state) }
Row(verticalAlignment = Alignment.CenterVertically) {
KeyboardIconsSet.instance.GetIcon(item.name)
val text = item.name.lowercase().getStringResourceOrName("", ctx)
Text(text, Modifier.weight(1f))
Switch(
checked = checked,
onCheckedChange = { item.state = it; checked = it }
)
}
},
getKey = { it.name }
)
}
}
private class KeyAndState(var name: String, var state: Boolean)
@Suppress("UNCHECKED_CAST")
private fun <T: Any> getPrefOfType(prefs: SharedPreferences, key: String, default: T): T =
when (default) {
is String -> prefs.getString(key, default)
is Int -> prefs.getInt(key, default)
is Long -> prefs.getLong(key, default)
is Float -> prefs.getFloat(key, default)
is Boolean -> prefs.getBoolean(key, default)
else -> throw IllegalArgumentException("unknown type ${default.javaClass}")
} as T
private fun <T: Any> putPrefOfType(prefs: SharedPreferences, key: String, value: T) =
prefs.edit {
when (value) {
is String -> putString(key, value)
is Int -> putInt(key, value)
is Long -> putLong(key, value)
is Float -> putFloat(key, value)
is Boolean -> putBoolean(key, value)
else -> throw IllegalArgumentException("unknown type ${value.javaClass}")
}
}
@Preview
@Composable
private fun PreferencePreview() {
Surface {
Column {
PreferenceCategory("Preference Category")
Preference(
name = "Preference",
onClick = {},
)
Preference(
name = "Preference with icon",
onClick = {},
icon = R.drawable.ic_settings_about_foreground
)
SliderPreference(
name = "SliderPreference",
pref = "",
default = 1,
description = { it.toString() },
range = -5f..5f
)
Preference(
name = "Preference with icon and description",
description = "some text",
onClick = {},
icon = R.drawable.ic_settings_about_foreground
)
Preference(
name = "Preference with switch",
onClick = {}
) {
Switch(checked = true, onCheckedChange = {})
}
SwitchPreference(
name = "SwitchPreference",
pref = "none",
default = true
)
Preference(
name = "Preference",
onClick = {},
description = "A long description which may actually be several lines long, so it should wrap."
) {
Icon(painterResource(R.drawable.ic_arrow_left), null)
}
Preference(
name = "Long preference name that wraps",
onClick = {},
) {
Text("Long preference value")
}
Preference(
name = "Long preference name 2",
onClick = {},
description = "hello I am description"
) {
Text("Long preference value")
}
}
}
}

View file

@ -43,6 +43,7 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.unit.dp
import helium314.keyboard.latin.R
import helium314.keyboard.settings.preferences.PreferenceCategory
@Composable
fun SearchPrefScreen(

View file

@ -14,7 +14,6 @@ import helium314.keyboard.latin.utils.prefs
import kotlinx.coroutines.flow.MutableStateFlow
// todo (roughly in order)
// maybe move some large prefs out of their screens into separate files (backup/restore!)
// use better / more structured and clear names and arrangement of files
// the prefDef and AllPrefs, also be clear about pref <-> key <-> prefKey (all used, probably should be latter)
// there is a lot more ambiguous naming...

View file

@ -0,0 +1,126 @@
package helium314.keyboard.settings.preferences
import android.app.Activity
import android.content.Context
import android.content.Intent
import android.graphics.BitmapFactory
import android.net.Uri
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import helium314.keyboard.latin.R
import helium314.keyboard.latin.common.FileUtils
import helium314.keyboard.latin.settings.Settings
import helium314.keyboard.latin.utils.Log
import helium314.keyboard.latin.utils.getActivity
import helium314.keyboard.latin.utils.prefs
import helium314.keyboard.settings.PrefDef
import helium314.keyboard.settings.SettingsActivity2
import helium314.keyboard.settings.dialogs.ConfirmationDialog
import helium314.keyboard.settings.dialogs.InfoDialog
import helium314.keyboard.settings.keyboardNeedsReload
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@Composable
fun BackgroundImagePref(def: PrefDef, isLandscape: Boolean) {
var showDayNightDialog by remember { mutableStateOf(false) }
var showSelectionDialog by remember { mutableStateOf(false) }
var showErrorDialog by remember { mutableStateOf(false) }
var isNight by remember { mutableStateOf(false) }
val ctx = LocalContext.current
fun getFile() = Settings.getCustomBackgroundFile(ctx, isNight, isLandscape)
val b = (ctx.getActivity() as? SettingsActivity2)?.prefChanged?.collectAsState()
if ((b?.value ?: 0) < 0) // necessary to reload dayNightPref
Log.v("irrelevant", "stupid way to trigger recomposition on preference change")
val dayNightPref = Settings.readDayNightPref(ctx.prefs(), ctx.resources)
if (!dayNightPref)
isNight = false
val scope = rememberCoroutineScope()
val launcher = rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
if (result.resultCode != Activity.RESULT_OK) return@rememberLauncherForActivityResult
val uri = result.data?.data ?: return@rememberLauncherForActivityResult
showSelectionDialog = false
showDayNightDialog = false
scope.launch(Dispatchers.IO) {
if (!setBackgroundImage(ctx, uri, isNight, isLandscape))
showErrorDialog = true
}
}
val intent = Intent(Intent.ACTION_OPEN_DOCUMENT)
.addCategory(Intent.CATEGORY_OPENABLE)
.setType("image/*")
Preference(
name = def.title,
onClick = {
if (dayNightPref) {
showDayNightDialog = true
} else if (!getFile().exists()) {
launcher.launch(intent)
} else {
showSelectionDialog = true
}
}
)
if (showDayNightDialog) {
ConfirmationDialog(
onDismissRequest = { showDayNightDialog = false },
onConfirmed = {
isNight = false
if (getFile().exists())
showSelectionDialog = true
else launcher.launch(intent)
},
confirmButtonText = stringResource(R.string.day_or_night_day),
cancelButtonText = "",
onNeutral = {
isNight = true
if (getFile().exists())
showSelectionDialog = true
else launcher.launch(intent)
},
neutralButtonText = stringResource(R.string.day_or_night_night),
title = { Text(stringResource(R.string.day_or_night_image)) },
)
}
if (showSelectionDialog) {
ConfirmationDialog(
onDismissRequest = { showSelectionDialog = false },
title = { Text(stringResource(R.string.customize_background_image)) },
confirmButtonText = stringResource(R.string.button_load_custom),
onConfirmed = { launcher.launch(intent) },
neutralButtonText = stringResource(R.string.delete),
onNeutral = {
getFile().delete()
Settings.clearCachedBackgroundImages()
keyboardNeedsReload = true
}
)
}
if (showErrorDialog) {
InfoDialog(stringResource(R.string.file_read_error)) { showErrorDialog = false }
}
}
private fun setBackgroundImage(ctx: Context, uri: Uri, isNight: Boolean, isLandscape: Boolean): Boolean {
val imageFile = Settings.getCustomBackgroundFile(ctx, isNight, isLandscape)
FileUtils.copyContentUriToNewFile(uri, ctx, imageFile)
keyboardNeedsReload = true
try {
BitmapFactory.decodeFile(imageFile.absolutePath)
} catch (_: Exception) {
imageFile.delete()
return false
}
Settings.clearCachedBackgroundImages()
return true
}

View file

@ -0,0 +1,303 @@
package helium314.keyboard.settings.preferences
import android.app.Activity
import android.content.Intent
import android.content.SharedPreferences
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.preference.PreferenceManager
import helium314.keyboard.dictionarypack.DictionaryPackConstants
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.latin.R
import helium314.keyboard.latin.checkVersionUpgrade
import helium314.keyboard.latin.common.FileUtils
import helium314.keyboard.latin.common.LocaleUtils.constructLocale
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_LAYOUT_PREFIX
import helium314.keyboard.latin.utils.DeviceProtectedUtils
import helium314.keyboard.latin.utils.ExecutorUtils
import helium314.keyboard.latin.utils.Log
import helium314.keyboard.latin.utils.getActivity
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.PrefDef
import helium314.keyboard.settings.SettingsActivity2
import helium314.keyboard.settings.dialogs.ConfirmationDialog
import helium314.keyboard.settings.dialogs.InfoDialog
import helium314.keyboard.settings.keyboardNeedsReload
import kotlinx.serialization.json.Json
import java.io.File
import java.io.FileInputStream
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 BackupRestorePreference(def: PrefDef) {
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()) { result ->
if (result.resultCode != Activity.RESULT_OK) return@rememberLauncherForActivityResult
val uri = result.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()) { result ->
if (result.resultCode != Activity.RESULT_OK) return@rememberLauncherForActivityResult
val uri = result.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 = def.title,
onClick = { showDialog = true }
)
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 }
}
}
@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
}
}
private const val PREFS_FILE_NAME = "preferences.json"
private const val PROTECTED_PREFS_FILE_NAME = "protected_preferences.json"

View file

@ -0,0 +1,74 @@
package helium314.keyboard.settings.preferences
import android.app.Activity
import android.content.Intent
import android.graphics.Typeface
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import helium314.keyboard.latin.R
import helium314.keyboard.latin.common.FileUtils
import helium314.keyboard.latin.settings.Settings
import helium314.keyboard.latin.utils.DeviceProtectedUtils
import helium314.keyboard.settings.PrefDef
import helium314.keyboard.settings.dialogs.ConfirmationDialog
import helium314.keyboard.settings.dialogs.InfoDialog
import helium314.keyboard.settings.keyboardNeedsReload
import java.io.File
@Composable
fun CustomFontPreference(def: PrefDef) {
val ctx = LocalContext.current
var showDialog by remember { mutableStateOf(false) }
var showErrorDialog by remember { mutableStateOf(false) }
val fontFile = Settings.getCustomFontFile(ctx)
val launcher = rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) {
if (it.resultCode != Activity.RESULT_OK) return@rememberLauncherForActivityResult
val uri = it.data?.data ?: return@rememberLauncherForActivityResult
val tempFile = File(DeviceProtectedUtils.getFilesDir(ctx), "temp_file")
FileUtils.copyContentUriToNewFile(uri, ctx, tempFile)
try {
Typeface.createFromFile(tempFile)
fontFile.delete()
tempFile.renameTo(fontFile)
Settings.clearCachedTypeface()
keyboardNeedsReload = true
} catch (_: Exception) {
showErrorDialog = true
tempFile.delete()
}
}
val intent = Intent(Intent.ACTION_OPEN_DOCUMENT)
.addCategory(Intent.CATEGORY_OPENABLE)
.setType("*/*")
Preference(
name = def.title,
onClick = {
if (fontFile.exists())
showDialog = true
else launcher.launch(intent)
},
)
if (showDialog)
ConfirmationDialog(
onDismissRequest = { showDialog = false },
onConfirmed = { launcher.launch(intent) },
onNeutral = {
fontFile.delete()
Settings.clearCachedTypeface()
keyboardNeedsReload = true
},
neutralButtonText = stringResource(R.string.delete),
confirmButtonText = stringResource(R.string.load),
title = { Text(stringResource(R.string.custom_font)) }
)
if (showErrorDialog)
InfoDialog(stringResource(R.string.file_read_error)) { showErrorDialog = false }
}

View file

@ -0,0 +1,57 @@
package helium314.keyboard.settings.preferences
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.platform.LocalContext
import helium314.keyboard.latin.utils.CUSTOM_LAYOUT_PREFIX
import helium314.keyboard.latin.utils.getCustomLayoutFiles
import helium314.keyboard.settings.PrefDef
import helium314.keyboard.settings.dialogs.LayoutEditDialog
import helium314.keyboard.settings.dialogs.ListPickerDialog
import java.io.File
@Composable
fun LayoutEditPreference(
def: PrefDef,
items: List<String>,
getItemName: @Composable (String) -> String,
getDefaultLayout: @Composable (String?) -> String?,
) {
var showDialog by remember { mutableStateOf(false) }
val ctx = LocalContext.current
var layout: String? by remember { mutableStateOf(null) }
Preference(
name = def.title,
onClick = { showDialog = true }
)
if (showDialog) {
ListPickerDialog(
onDismissRequest = { showDialog = false },
showRadioButtons = false,
confirmImmediately = true,
items = items,
getItemName = getItemName,
onItemSelected = { layout = it },
title = { Text(def.title) }
)
}
if (layout != null) {
val customLayoutName = getCustomLayoutFiles(ctx).firstOrNull {
if (layout!!.startsWith(CUSTOM_LAYOUT_PREFIX))
it.name.startsWith("$layout.")
else it.name.startsWith("$CUSTOM_LAYOUT_PREFIX$layout.")
}?.name
val originalLayout = if (customLayoutName != null) null
else getDefaultLayout(layout)?.let { ctx.assets.open("layouts" + File.separator + it).reader().readText() }
LayoutEditDialog(
layoutName = customLayoutName ?: "$CUSTOM_LAYOUT_PREFIX$layout.",
startContent = originalLayout,
displayName = getItemName(layout!!),
onDismissRequest = { layout = null }
)
}
}

View file

@ -0,0 +1,111 @@
package helium314.keyboard.settings.preferences
import android.app.Activity
import android.content.Intent
import android.os.Build
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AlertDialog
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import helium314.keyboard.latin.R
import helium314.keyboard.latin.common.FileUtils
import helium314.keyboard.latin.settings.Settings
import helium314.keyboard.latin.utils.ChecksumCalculator
import helium314.keyboard.latin.utils.JniUtils
import helium314.keyboard.latin.utils.prefs
import helium314.keyboard.settings.PrefDef
import helium314.keyboard.settings.dialogs.ConfirmationDialog
import java.io.File
import java.io.FileInputStream
import java.io.FileOutputStream
import java.io.IOException
@Composable
fun LoadGestureLibPreference(def: PrefDef) {
var showDialog by remember { mutableStateOf(false) }
val ctx = LocalContext.current
val prefs = ctx.prefs()
val abi = Build.SUPPORTED_ABIS[0]
val libFile = File(ctx.filesDir.absolutePath + File.separator + JniUtils.JNI_LIB_IMPORT_FILE_NAME)
fun renameToLibFileAndRestart(file: File, checksum: String) {
libFile.delete()
// store checksum in default preferences (soo JniUtils)
prefs.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
}
var tempFilePath: String? by remember { mutableStateOf(null) }
val launcher = rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
if (result.resultCode != Activity.RESULT_OK) return@rememberLauncherForActivityResult
val uri = result.data?.data ?: return@rememberLauncherForActivityResult
val tmpfile = File(ctx.filesDir.absolutePath + File.separator + "tmplib")
try {
val otherTemporaryFile = File(ctx.filesDir.absolutePath + File.separator + "tmpfile")
FileUtils.copyContentUriToNewFile(uri, ctx, otherTemporaryFile)
val inputStream = FileInputStream(otherTemporaryFile)
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)
}
otherTemporaryFile.delete()
val checksum = ChecksumCalculator.checksum(tmpfile.inputStream()) ?: ""
if (checksum == JniUtils.expectedDefaultChecksum()) {
renameToLibFileAndRestart(tmpfile, checksum)
} else {
tempFilePath = tmpfile.absolutePath
AlertDialog.Builder(ctx)
.setMessage(ctx.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
}
}
Preference(
name = def.title,
onClick = { showDialog = true }
)
if (showDialog) {
ConfirmationDialog(
onDismissRequest = { showDialog = false },
onConfirmed = {
val intent = Intent(Intent.ACTION_OPEN_DOCUMENT)
.addCategory(Intent.CATEGORY_OPENABLE)
.setType("application/octet-stream")
launcher.launch(intent)
},
title = { Text(stringResource(R.string.load_gesture_library)) },
text = { Text(stringResource(R.string.load_gesture_library_message, abi)) },
neutralButtonText = if (libFile.exists()) stringResource(R.string.load_gesture_library_button_delete) else null,
onNeutral = {
libFile.delete()
prefs.edit().remove(Settings.PREF_LIBRARY_CHECKSUM).commit()
Runtime.getRuntime().exit(0)
}
)
}
if (tempFilePath != null)
ConfirmationDialog(
onDismissRequest = {
File(tempFilePath!!).delete()
tempFilePath = null
},
text = { Text(stringResource(R.string.checksum_mismatch_message, abi)) },
onConfirmed = {
val tempFile = File(tempFilePath!!)
renameToLibFileAndRestart(tempFile, ChecksumCalculator.checksum(tempFile.inputStream()) ?: "")
}
)
}

View file

@ -0,0 +1,166 @@
// SPDX-License-Identifier: GPL-3.0-only
package helium314.keyboard.settings.preferences
import androidx.annotation.DrawableRes
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.style.Hyphens
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import helium314.keyboard.latin.R
// partially taken from StreetComplete / SCEE
@Composable
fun PreferenceCategory(
title: String,
modifier: Modifier = Modifier,
) {
Column {
HorizontalDivider()
Text(
text = title,
modifier = modifier.padding(top = 12.dp, start = 16.dp, end = 8.dp, bottom = 8.dp),
color = MaterialTheme.colorScheme.secondary,
style = MaterialTheme.typography.titleSmall
)
}
}
@Composable
fun Preference(
name: String,
onClick: () -> Unit,
modifier: Modifier = Modifier,
description: String? = null,
@DrawableRes icon: Int? = null,
value: @Composable (RowScope.() -> Unit)? = null,
) {
Row(
modifier = modifier
.fillMaxWidth()
.clickable { onClick() }
.heightIn(min = 44.dp)
.padding(12.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.CenterVertically
) {
if (icon != null)
Icon(painterResource(icon), name, modifier = Modifier.size(36.dp))
Column(modifier = Modifier.weight(1f)) {
Text(text = name, style = MaterialTheme.typography.bodyLarge)
if (description != null) {
CompositionLocalProvider(
LocalTextStyle provides MaterialTheme.typography.bodyMedium,
LocalContentColor provides MaterialTheme.colorScheme.onSurfaceVariant
) {
Text(
text = description,
modifier = Modifier.padding(top = 2.dp)
)
}
}
}
if (value != null) {
CompositionLocalProvider(
LocalTextStyle provides LocalTextStyle.current.copy(
textAlign = TextAlign.End,
hyphens = Hyphens.Auto
),
LocalContentColor provides MaterialTheme.colorScheme.onSurfaceVariant
) {
Row(
horizontalArrangement = Arrangement.spacedBy(
space = 8.dp,
alignment = Alignment.End
),
verticalAlignment = Alignment.CenterVertically,
) { value() }
}
}
}
}
@Preview
@Composable
private fun PreferencePreview() {
Surface {
Column {
PreferenceCategory("Preference Category")
Preference(
name = "Preference",
onClick = {},
)
Preference(
name = "Preference with icon",
onClick = {},
icon = R.drawable.ic_settings_about_foreground
)
SliderPreference(
name = "SliderPreference",
pref = "",
default = 1,
description = { it.toString() },
range = -5f..5f
)
Preference(
name = "Preference with icon and description",
description = "some text",
onClick = {},
icon = R.drawable.ic_settings_about_foreground
)
Preference(
name = "Preference with switch",
onClick = {}
) {
Switch(checked = true, onCheckedChange = {})
}
SwitchPreference(
name = "SwitchPreference",
pref = "none",
default = true
)
Preference(
name = "Preference",
onClick = {},
description = "A long description which may actually be several lines long, so it should wrap."
) {
Icon(painterResource(R.drawable.ic_arrow_left), null)
}
Preference(
name = "Long preference name that wraps",
onClick = {},
) {
Text("Long preference value")
}
Preference(
name = "Long preference name 2",
onClick = {},
description = "hello I am description"
) {
Text("Long preference value")
}
}
}
}

View file

@ -0,0 +1,67 @@
package helium314.keyboard.settings.preferences
import androidx.compose.foundation.layout.Row
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import helium314.keyboard.keyboard.internal.KeyboardIconsSet
import helium314.keyboard.latin.R
import helium314.keyboard.latin.utils.getStringResourceOrName
import helium314.keyboard.latin.utils.prefs
import helium314.keyboard.settings.PrefDef
import helium314.keyboard.settings.dialogs.ReorderDialog
import helium314.keyboard.settings.keyboardNeedsReload
import helium314.keyboard.settings.screens.GetIcon
@Composable
fun ReorderSwitchPreference(def: PrefDef, default: String) {
var showDialog by remember { mutableStateOf(false) }
Preference(
name = def.title,
description = def.description,
onClick = { showDialog = true },
)
if (showDialog) {
val ctx = LocalContext.current
val prefs = ctx.prefs()
val items = prefs.getString(def.key, default)!!.split(";").mapTo(ArrayList()) {
val both = it.split(",")
KeyAndState(both.first(), both.last().toBoolean())
}
ReorderDialog(
onConfirmed = { reorderedItems ->
val value = reorderedItems.joinToString(";") { it.name + "," + it.state }
prefs.edit().putString(def.key, value).apply()
keyboardNeedsReload = true
},
onDismissRequest = { showDialog = false },
onNeutral = { prefs.edit().remove(def.key).apply() },
neutralButtonText = if (prefs.contains(def.key)) stringResource(R.string.button_default) else null,
items = items,
title = { Text(def.title) },
displayItem = { item ->
var checked by remember { mutableStateOf(item.state) }
Row(verticalAlignment = Alignment.CenterVertically) {
KeyboardIconsSet.instance.GetIcon(item.name)
val text = item.name.lowercase().getStringResourceOrName("", ctx)
Text(text, Modifier.weight(1f))
Switch(
checked = checked,
onCheckedChange = { item.state = it; checked = it }
)
}
},
getKey = { it.name }
)
}
}
private class KeyAndState(var name: String, var state: Boolean)

View file

@ -0,0 +1,127 @@
package helium314.keyboard.settings.preferences
import android.content.SharedPreferences
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.core.content.edit
import helium314.keyboard.latin.utils.Log
import helium314.keyboard.latin.utils.getActivity
import helium314.keyboard.latin.utils.prefs
import helium314.keyboard.settings.PrefDef
import helium314.keyboard.settings.SettingsActivity2
import helium314.keyboard.settings.dialogs.ListPickerDialog
import helium314.keyboard.settings.dialogs.SliderDialog
import kotlin.math.roundToInt
@Composable
/** Slider preference for Int or Float (weird casting stuff, but should be fine) */
fun <T: Number> SliderPreference(
name: String,
modifier: Modifier = Modifier,
pref: String,
description: @Composable (T) -> String,
default: T,
range: ClosedFloatingPointRange<Float>,
stepSize: Int? = null,
onValueChanged: (Float) -> Unit = { },
) {
val ctx = LocalContext.current
val prefs = ctx.prefs()
val b = (ctx.getActivity() as? SettingsActivity2)?.prefChanged?.collectAsState()
if ((b?.value ?: 0) < 0)
Log.v("irrelevant", "stupid way to trigger recomposition on preference change")
val initialValue = if (default is Int || default is Float)
getPrefOfType(prefs, pref, default)
else throw IllegalArgumentException("only float and int are supported")
var showDialog by remember { mutableStateOf(false) }
Preference(
name = name,
onClick = { showDialog = true },
modifier = modifier,
description = description(initialValue)
)
if (showDialog)
SliderDialog(
onDismissRequest = { showDialog = false },
onDone = {
if (default is Int) prefs.edit().putInt(pref, it.toInt()).apply()
else prefs.edit().putFloat(pref, it).apply()
},
initialValue = initialValue.toFloat(),
range = range,
positionString = {
@Suppress("UNCHECKED_CAST")
description((if (default is Int) it.roundToInt() else it) as T)
},
onValueChanged = onValueChanged,
showDefault = true,
onDefault = { prefs.edit().remove(pref).apply() },
intermediateSteps = stepSize?.let {
// this is not nice, but slider wants it like this...
((range.endInclusive - range.start) / it - 1).toInt()
}
)
}
@Composable
// just in here so we can keep getPrefOfType private... rename file?
fun <T: Any> ListPreference(
def: PrefDef,
items: List<Pair<String, T>>,
default: T,
onChanged: (T) -> Unit = { }
) {
var showDialog by remember { mutableStateOf(false) }
val prefs = LocalContext.current.prefs()
val selected = items.firstOrNull { it.second == getPrefOfType(prefs, def.key, default) }
Preference(
name = def.title,
description = selected?.first,
onClick = { showDialog = true }
)
if (showDialog) {
ListPickerDialog(
onDismissRequest = { showDialog = false },
items = items,
onItemSelected = {
if (it == selected) return@ListPickerDialog
putPrefOfType(prefs, def.key, it.second)
onChanged(it.second)
},
selectedItem = selected,
title = { Text(def.title) },
getItemName = { it.first }
)
}
}
@Suppress("UNCHECKED_CAST")
private fun <T: Any> getPrefOfType(prefs: SharedPreferences, key: String, default: T): T =
when (default) {
is String -> prefs.getString(key, default)
is Int -> prefs.getInt(key, default)
is Long -> prefs.getLong(key, default)
is Float -> prefs.getFloat(key, default)
is Boolean -> prefs.getBoolean(key, default)
else -> throw IllegalArgumentException("unknown type ${default.javaClass}")
} as T
private fun <T: Any> putPrefOfType(prefs: SharedPreferences, key: String, value: T) =
prefs.edit {
when (value) {
is String -> putString(key, value)
is Int -> putInt(key, value)
is Long -> putLong(key, value)
is Float -> putFloat(key, value)
is Boolean -> putBoolean(key, value)
else -> throw IllegalArgumentException("unknown type ${value.javaClass}")
}
}

View file

@ -0,0 +1,68 @@
package helium314.keyboard.settings.preferences
import androidx.compose.material3.Switch
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import helium314.keyboard.latin.utils.Log
import helium314.keyboard.latin.utils.getActivity
import helium314.keyboard.latin.utils.prefs
import helium314.keyboard.settings.PrefDef
import helium314.keyboard.settings.SettingsActivity2
@Composable
fun SwitchPreference(
name: String,
modifier: Modifier = Modifier,
pref: String,
default: Boolean,
description: String? = null,
allowCheckedChange: (Boolean) -> Boolean = { true }, // true means ok, usually for showing some dialog
onCheckedChange: (Boolean) -> Unit = { },
) {
val ctx = LocalContext.current
val prefs = ctx.prefs()
val b = (ctx.getActivity() as? SettingsActivity2)?.prefChanged?.collectAsState()
if ((b?.value ?: 0) < 0)
Log.v("irrelevant", "stupid way to trigger recomposition on preference change")
var value = prefs.getBoolean(pref, default)
fun switched(newValue: Boolean) {
if (!allowCheckedChange(newValue)) {
value = !newValue
return
}
value = newValue
prefs.edit().putBoolean(pref, newValue).apply()
onCheckedChange(newValue)
}
Preference(
name = name,
onClick = { switched(!value) },
modifier = modifier,
description = description
) {
Switch(
checked = value,
onCheckedChange = { switched(it) },
)
}
}
@Composable
fun SwitchPreference(
def: PrefDef,
default: Boolean,
allowCheckedChange: (Boolean) -> Boolean = { true },
onCheckedChange: (Boolean) -> Unit = { }
) {
SwitchPreference(
name = def.title,
description = def.description,
pref = def.key,
default = default,
allowCheckedChange = allowCheckedChange,
onCheckedChange = onCheckedChange
)
}

View file

@ -32,7 +32,7 @@ import helium314.keyboard.latin.utils.prefs
import helium314.keyboard.settings.AllPrefs
import helium314.keyboard.settings.NonSettingsPrefs
import helium314.keyboard.settings.PrefDef
import helium314.keyboard.settings.Preference
import helium314.keyboard.settings.preferences.Preference
import helium314.keyboard.settings.SearchPrefScreen
import helium314.keyboard.settings.SettingsActivity2
import helium314.keyboard.settings.Theme

View file

@ -2,14 +2,8 @@
package helium314.keyboard.settings.screens
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
import androidx.appcompat.app.AlertDialog
import androidx.compose.material3.Icon
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
@ -24,73 +18,37 @@ 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.KeyboardActionListener
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.preferences.ListPreference
import helium314.keyboard.settings.NonSettingsPrefs
import helium314.keyboard.settings.PrefDef
import helium314.keyboard.settings.Preference
import helium314.keyboard.settings.preferences.Preference
import helium314.keyboard.settings.SearchPrefScreen
import helium314.keyboard.settings.SettingsActivity2
import helium314.keyboard.settings.SettingsDestination
import helium314.keyboard.settings.SliderPreference
import helium314.keyboard.settings.SwitchPreference
import helium314.keyboard.settings.preferences.SliderPreference
import helium314.keyboard.settings.preferences.SwitchPreference
import helium314.keyboard.settings.Theme
import helium314.keyboard.settings.dialogs.ConfirmationDialog
import helium314.keyboard.settings.dialogs.InfoDialog
import helium314.keyboard.settings.dialogs.LayoutEditDialog
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
import helium314.keyboard.settings.preferences.BackupRestorePreference
import helium314.keyboard.settings.preferences.LayoutEditPreference
import helium314.keyboard.settings.preferences.LoadGestureLibPreference
@Composable
fun AdvancedSettingsScreen(
@ -236,241 +194,24 @@ fun createAdvancedPrefs(context: Context) = listOf(
ListPreference(def, items, "main") { KeyboardLayoutSet.onSystemLocaleChanged() }
},
PrefDef(context, NonSettingsPrefs.CUSTOM_SYMBOLS_NUMBER_LAYOUTS, R.string.customize_symbols_number_layouts) { def ->
var showDialog by remember { mutableStateOf(false) }
val ctx = LocalContext.current
var layout: String? by remember { mutableStateOf(null) }
Preference(
name = def.title,
onClick = { showDialog = true }
LayoutEditPreference(
def = def,
items = RawKeyboardParser.symbolAndNumberLayouts,
getItemName = { it.getStringResourceOrName("layout_", LocalContext.current) },
getDefaultLayout = { LocalContext.current.assets.list("layouts")?.firstOrNull { it.startsWith("$it.") } }
)
if (showDialog) {
ListPickerDialog(
onDismissRequest = { showDialog = false },
showRadioButtons = false,
confirmImmediately = true,
items = RawKeyboardParser.symbolAndNumberLayouts,
getItemName = { it.getStringResourceOrName("layout_", ctx) },
onItemSelected = { layout = it },
title = { Text(def.title) }
)
}
if (layout != null) {
val customLayoutName = getCustomLayoutFiles(ctx).firstOrNull { it.name.startsWith("$CUSTOM_LAYOUT_PREFIX$layout.")}?.name
val originalLayout = if (customLayoutName != null) null
else {
ctx.assets.list("layouts")?.firstOrNull { it.startsWith("$layout.") }
?.let { ctx.assets.open("layouts" + File.separator + it).reader().readText() }
}
LayoutEditDialog(
layoutName = customLayoutName ?: "$CUSTOM_LAYOUT_PREFIX$layout.",
startContent = originalLayout,
displayName = layout?.getStringResourceOrName("layout_", ctx),
onDismissRequest = { layout = null}
)
}
},
PrefDef(context, NonSettingsPrefs.CUSTOM_FUNCTIONAL_LAYOUTS, R.string.customize_functional_key_layouts) { def ->
var showDialog by remember { mutableStateOf(false) }
val ctx = LocalContext.current
var layout: String? by remember { mutableStateOf(null) }
Preference(
name = def.title,
onClick = { showDialog = true }
LayoutEditPreference(
def = def,
items = listOf(CUSTOM_FUNCTIONAL_LAYOUT_NORMAL, CUSTOM_FUNCTIONAL_LAYOUT_SYMBOLS, CUSTOM_FUNCTIONAL_LAYOUT_SYMBOLS_SHIFTED)
.map { it.substringBeforeLast(".") },
getItemName = { it.substringAfter(CUSTOM_LAYOUT_PREFIX).getStringResourceOrName("layout_", LocalContext.current) },
getDefaultLayout = { if (Settings.getInstance().isTablet) "functional_keys_tablet.json" else "functional_keys.json" }
)
if (showDialog) {
ListPickerDialog(
onDismissRequest = { showDialog = false },
showRadioButtons = false,
confirmImmediately = true,
items = listOf(CUSTOM_FUNCTIONAL_LAYOUT_NORMAL, CUSTOM_FUNCTIONAL_LAYOUT_SYMBOLS, CUSTOM_FUNCTIONAL_LAYOUT_SYMBOLS_SHIFTED)
.map { it.substringBeforeLast(".") },
getItemName = { it.substringAfter(CUSTOM_LAYOUT_PREFIX).getStringResourceOrName("layout_", ctx) },
onItemSelected = { layout = it },
title = { Text(def.title) }
)
}
if (layout != null) {
val customLayoutName = getCustomLayoutFiles(ctx).map { it.name }
.firstOrNull { it.startsWith("$layout.") }
val originalLayout = if (customLayoutName != null) null
else {
val defaultLayoutName = if (Settings.getInstance().isTablet) "functional_keys_tablet.json" else "functional_keys.json"
ctx.assets.open("layouts" + File.separator + defaultLayoutName).reader().readText()
}
LayoutEditDialog(
layoutName = customLayoutName ?: "$CUSTOM_LAYOUT_PREFIX$layout.",
startContent = originalLayout,
displayName = layout?.substringAfter(CUSTOM_LAYOUT_PREFIX)?.getStringResourceOrName("layout_", ctx),
onDismissRequest = { layout = null}
)
}
},
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()) { result ->
if (result.resultCode != Activity.RESULT_OK) return@rememberLauncherForActivityResult
val uri = result.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()) { result ->
if (result.resultCode != Activity.RESULT_OK) return@rememberLauncherForActivityResult
val uri = result.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 = def.title,
onClick = { showDialog = true }
)
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.BACKUP_RESTORE, R.string.backup_restore_title) {
BackupRestorePreference(it)
},
PrefDef(context, NonSettingsPrefs.DEBUG_SETTINGS, R.string.debug_settings_title) {
Preference(
@ -516,86 +257,8 @@ fun createAdvancedPrefs(context: Context) = listOf(
PrefDef(context, Settings.PREF_URL_DETECTION, R.string.url_detection_title, R.string.url_detection_summary) {
SwitchPreference(it, false)
},
PrefDef(context, NonSettingsPrefs.LOAD_GESTURE_LIB, R.string.load_gesture_library, R.string.load_gesture_library_summary) { def ->
var showDialog by remember { mutableStateOf(false) }
val ctx = LocalContext.current
val prefs = ctx.prefs()
val abi = Build.SUPPORTED_ABIS[0]
val libFile = File(ctx.filesDir.absolutePath + File.separator + JniUtils.JNI_LIB_IMPORT_FILE_NAME)
fun renameToLibFileAndRestart(file: File, checksum: String) {
libFile.delete()
// store checksum in default preferences (soo JniUtils)
prefs.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
}
var tempFilePath: String? by remember { mutableStateOf(null) }
val launcher = rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
if (result.resultCode != Activity.RESULT_OK) return@rememberLauncherForActivityResult
val uri = result.data?.data ?: return@rememberLauncherForActivityResult
val tmpfile = File(ctx.filesDir.absolutePath + File.separator + "tmplib")
try {
val otherTemporaryFile = File(ctx.filesDir.absolutePath + File.separator + "tmpfile")
FileUtils.copyContentUriToNewFile(uri, ctx, otherTemporaryFile)
val inputStream = FileInputStream(otherTemporaryFile)
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)
}
otherTemporaryFile.delete()
val checksum = ChecksumCalculator.checksum(tmpfile.inputStream()) ?: ""
if (checksum == JniUtils.expectedDefaultChecksum()) {
renameToLibFileAndRestart(tmpfile, checksum)
} else {
tempFilePath = tmpfile.absolutePath
AlertDialog.Builder(ctx)
.setMessage(ctx.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
}
}
Preference(
name = def.title,
onClick = { showDialog = true }
)
if (showDialog) {
ConfirmationDialog(
onDismissRequest = { showDialog = false },
onConfirmed = {
val intent = Intent(Intent.ACTION_OPEN_DOCUMENT)
.addCategory(Intent.CATEGORY_OPENABLE)
.setType("application/octet-stream")
launcher.launch(intent)
},
title = { Text(stringResource(R.string.load_gesture_library)) },
text = { Text(stringResource(R.string.load_gesture_library_message, abi)) },
neutralButtonText = if (libFile.exists()) stringResource(R.string.load_gesture_library_button_delete) else null,
onNeutral = {
libFile.delete()
prefs.edit().remove(Settings.PREF_LIBRARY_CHECKSUM).commit()
Runtime.getRuntime().exit(0)
}
)
}
if (tempFilePath != null)
ConfirmationDialog(
onDismissRequest = {
File(tempFilePath!!).delete()
tempFilePath = null
},
text = { Text(stringResource(R.string.checksum_mismatch_message, abi))},
onConfirmed = {
val tempFile = File(tempFilePath!!)
renameToLibFileAndRestart(tempFile, ChecksumCalculator.checksum(tempFile.inputStream()) ?: "")
}
)
PrefDef(context, NonSettingsPrefs.LOAD_GESTURE_LIB, R.string.load_gesture_library, R.string.load_gesture_library_summary) {
LoadGestureLibPreference(it)
},
)
@ -609,84 +272,3 @@ 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
}
}

View file

@ -1,15 +1,8 @@
// SPDX-License-Identifier: GPL-3.0-only
package helium314.keyboard.settings.screens
import android.app.Activity
import android.content.Context
import android.content.Intent
import android.graphics.BitmapFactory
import android.graphics.Typeface
import android.net.Uri
import android.os.Build
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@ -17,7 +10,6 @@ import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
@ -25,35 +17,30 @@ import androidx.compose.ui.tooling.preview.Preview
import helium314.keyboard.keyboard.KeyboardSwitcher
import helium314.keyboard.keyboard.KeyboardTheme
import helium314.keyboard.latin.R
import helium314.keyboard.latin.common.FileUtils
import helium314.keyboard.latin.settings.ColorsNightSettingsFragment
import helium314.keyboard.latin.settings.ColorsSettingsFragment
import helium314.keyboard.latin.settings.Settings
import helium314.keyboard.latin.settings.SettingsValues
import helium314.keyboard.latin.utils.DeviceProtectedUtils
import helium314.keyboard.latin.utils.Log
import helium314.keyboard.latin.utils.getActivity
import helium314.keyboard.latin.utils.getStringResourceOrName
import helium314.keyboard.latin.utils.prefs
import helium314.keyboard.latin.utils.switchTo
import helium314.keyboard.settings.AllPrefs
import helium314.keyboard.settings.ListPreference
import helium314.keyboard.settings.preferences.ListPreference
import helium314.keyboard.settings.NonSettingsPrefs
import helium314.keyboard.settings.PrefDef
import helium314.keyboard.settings.Preference
import helium314.keyboard.settings.preferences.Preference
import helium314.keyboard.settings.SearchPrefScreen
import helium314.keyboard.settings.SettingsActivity2
import helium314.keyboard.settings.SliderPreference
import helium314.keyboard.settings.SwitchPreference
import helium314.keyboard.settings.preferences.SliderPreference
import helium314.keyboard.settings.preferences.SwitchPreference
import helium314.keyboard.settings.Theme
import helium314.keyboard.settings.dialogs.ConfirmationDialog
import helium314.keyboard.settings.dialogs.CustomizeIconsDialog
import helium314.keyboard.settings.dialogs.InfoDialog
import helium314.keyboard.settings.dialogs.TextInputDialog
import helium314.keyboard.settings.keyboardNeedsReload
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import java.io.File
import helium314.keyboard.settings.preferences.BackgroundImagePref
import helium314.keyboard.settings.preferences.CustomFontPreference
@Composable
fun AppearanceScreen(
@ -301,53 +288,8 @@ fun createAppearancePrefs(context: Context) = listOf(
)
}
},
PrefDef(context, NonSettingsPrefs.CUSTOM_FONT, R.string.custom_font) { def ->
val ctx = LocalContext.current
var showDialog by remember { mutableStateOf(false) }
var showErrorDialog by remember { mutableStateOf(false) }
val fontFile = Settings.getCustomFontFile(ctx)
val launcher = rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) {
if (it.resultCode != Activity.RESULT_OK) return@rememberLauncherForActivityResult
val uri = it.data?.data ?: return@rememberLauncherForActivityResult
val tempFile = File(DeviceProtectedUtils.getFilesDir(context), "temp_file")
FileUtils.copyContentUriToNewFile(uri, ctx, tempFile)
try {
Typeface.createFromFile(tempFile)
fontFile.delete()
tempFile.renameTo(fontFile)
Settings.clearCachedTypeface()
keyboardNeedsReload = true
} catch (_: Exception) {
showErrorDialog = true
tempFile.delete()
}
}
val intent = Intent(Intent.ACTION_OPEN_DOCUMENT)
.addCategory(Intent.CATEGORY_OPENABLE)
.setType("*/*")
Preference(
name = def.title,
onClick = {
if (fontFile.exists())
showDialog = true
else launcher.launch(intent)
},
)
if (showDialog)
ConfirmationDialog(
onDismissRequest = { showDialog = false },
onConfirmed = { launcher.launch(intent) },
onNeutral = {
fontFile.delete()
Settings.clearCachedTypeface()
keyboardNeedsReload = true
},
neutralButtonText = stringResource(R.string.delete),
confirmButtonText = stringResource(R.string.load),
title = { Text(stringResource(R.string.custom_font)) }
)
if (showErrorDialog)
InfoDialog(stringResource(R.string.file_read_error)) { showErrorDialog = false }
PrefDef(context, NonSettingsPrefs.CUSTOM_FONT, R.string.custom_font) {
CustomFontPreference(it)
},
PrefDef(context, Settings.PREF_FONT_SCALE, R.string.prefs_font_scale) { def ->
SliderPreference(
@ -369,100 +311,6 @@ fun createAppearancePrefs(context: Context) = listOf(
},
)
@Composable
private fun BackgroundImagePref(def: PrefDef, isLandscape: Boolean) {
var showDayNightDialog by remember { mutableStateOf(false) }
var showSelectionDialog by remember { mutableStateOf(false) }
var showErrorDialog by remember { mutableStateOf(false) }
var isNight by remember { mutableStateOf(false) }
val ctx = LocalContext.current
fun getFile() = Settings.getCustomBackgroundFile(ctx, isNight, isLandscape)
val b = (ctx.getActivity() as? SettingsActivity2)?.prefChanged?.collectAsState()
if ((b?.value ?: 0) < 0) // necessary to reload dayNightPref
Log.v("irrelevant", "stupid way to trigger recomposition on preference change")
val dayNightPref = Settings.readDayNightPref(ctx.prefs(), ctx.resources)
if (!dayNightPref)
isNight = false
val scope = rememberCoroutineScope()
val launcher = rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
if (result.resultCode != Activity.RESULT_OK) return@rememberLauncherForActivityResult
val uri = result.data?.data ?: return@rememberLauncherForActivityResult
showSelectionDialog = false
showDayNightDialog = false
scope.launch(Dispatchers.IO) {
if (!setBackgroundImage(ctx, uri, isNight, isLandscape))
showErrorDialog = true
}
}
val intent = Intent(Intent.ACTION_OPEN_DOCUMENT)
.addCategory(Intent.CATEGORY_OPENABLE)
.setType("image/*")
Preference(
name = def.title,
onClick = {
if (dayNightPref) {
showDayNightDialog = true
} else if (!getFile().exists()) {
launcher.launch(intent)
} else {
showSelectionDialog = true
}
}
)
if (showDayNightDialog) {
ConfirmationDialog(
onDismissRequest = { showDayNightDialog = false },
onConfirmed = {
isNight = false
if (getFile().exists())
showSelectionDialog = true
else launcher.launch(intent)
},
confirmButtonText = stringResource(R.string.day_or_night_day),
cancelButtonText = "",
onNeutral = {
isNight = true
if (getFile().exists())
showSelectionDialog = true
else launcher.launch(intent)
},
neutralButtonText = stringResource(R.string.day_or_night_night),
title = { Text(stringResource(R.string.day_or_night_image)) },
)
}
if (showSelectionDialog) {
ConfirmationDialog(
onDismissRequest = { showSelectionDialog = false },
title = { Text(stringResource(R.string.customize_background_image)) },
confirmButtonText = stringResource(R.string.button_load_custom),
onConfirmed = { launcher.launch(intent) },
neutralButtonText = stringResource(R.string.delete),
onNeutral = {
getFile().delete()
Settings.clearCachedBackgroundImages()
keyboardNeedsReload = true
}
)
}
if (showErrorDialog) {
InfoDialog(stringResource(R.string.file_read_error)) { showErrorDialog = false }
}
}
private fun setBackgroundImage(ctx: Context, uri: Uri, isNight: Boolean, isLandscape: Boolean): Boolean {
val imageFile = Settings.getCustomBackgroundFile(ctx, isNight, isLandscape)
FileUtils.copyContentUriToNewFile(uri, ctx, imageFile)
keyboardNeedsReload = true
try {
BitmapFactory.decodeFile(imageFile.absolutePath)
} catch (_: Exception) {
imageFile.delete()
return false
}
Settings.clearCachedBackgroundImages()
return true
}
@Preview
@Composable
private fun Preview() {

View file

@ -22,10 +22,10 @@ import helium314.keyboard.latin.settings.DebugSettingsFragment
import helium314.keyboard.latin.utils.prefs
import helium314.keyboard.settings.AllPrefs
import helium314.keyboard.settings.PrefDef
import helium314.keyboard.settings.Preference
import helium314.keyboard.settings.preferences.Preference
import helium314.keyboard.settings.SearchPrefScreen
import helium314.keyboard.settings.SettingsActivity2
import helium314.keyboard.settings.SwitchPreference
import helium314.keyboard.settings.preferences.SwitchPreference
import helium314.keyboard.settings.Theme
import helium314.keyboard.settings.dialogs.ConfirmationDialog
import helium314.keyboard.settings.keyboardNeedsReload

View file

@ -17,8 +17,8 @@ import helium314.keyboard.settings.AllPrefs
import helium314.keyboard.settings.PrefDef
import helium314.keyboard.settings.SearchPrefScreen
import helium314.keyboard.settings.SettingsActivity2
import helium314.keyboard.settings.SliderPreference
import helium314.keyboard.settings.SwitchPreference
import helium314.keyboard.settings.preferences.SliderPreference
import helium314.keyboard.settings.preferences.SwitchPreference
import helium314.keyboard.settings.Theme
import helium314.keyboard.settings.keyboardNeedsReload

View file

@ -25,8 +25,8 @@ import helium314.keyboard.latin.settings.ToolbarSettingsFragment
import helium314.keyboard.latin.utils.JniUtils
import helium314.keyboard.latin.utils.getActivity
import helium314.keyboard.latin.utils.switchTo
import helium314.keyboard.settings.Preference
import helium314.keyboard.settings.PreferenceCategory
import helium314.keyboard.settings.preferences.Preference
import helium314.keyboard.settings.preferences.PreferenceCategory
import helium314.keyboard.settings.SearchPrefScreen
import helium314.keyboard.settings.Theme

View file

@ -23,13 +23,13 @@ import helium314.keyboard.latin.utils.getEnabledSubtypes
import helium314.keyboard.latin.utils.locale
import helium314.keyboard.latin.utils.prefs
import helium314.keyboard.settings.AllPrefs
import helium314.keyboard.settings.ListPreference
import helium314.keyboard.settings.preferences.ListPreference
import helium314.keyboard.settings.PrefDef
import helium314.keyboard.settings.ReorderSwitchPreference
import helium314.keyboard.settings.preferences.ReorderSwitchPreference
import helium314.keyboard.settings.SearchPrefScreen
import helium314.keyboard.settings.SettingsActivity2
import helium314.keyboard.settings.SliderPreference
import helium314.keyboard.settings.SwitchPreference
import helium314.keyboard.settings.preferences.SliderPreference
import helium314.keyboard.settings.preferences.SwitchPreference
import helium314.keyboard.settings.Theme
import helium314.keyboard.settings.keyboardNeedsReload

View file

@ -29,13 +29,13 @@ import helium314.keyboard.latin.utils.getActivity
import helium314.keyboard.latin.utils.prefs
import helium314.keyboard.latin.utils.switchTo
import helium314.keyboard.settings.AllPrefs
import helium314.keyboard.settings.ListPreference
import helium314.keyboard.settings.preferences.ListPreference
import helium314.keyboard.settings.NonSettingsPrefs
import helium314.keyboard.settings.PrefDef
import helium314.keyboard.settings.Preference
import helium314.keyboard.settings.preferences.Preference
import helium314.keyboard.settings.SearchPrefScreen
import helium314.keyboard.settings.SettingsActivity2
import helium314.keyboard.settings.SwitchPreference
import helium314.keyboard.settings.preferences.SwitchPreference
import helium314.keyboard.settings.Theme
import helium314.keyboard.settings.dialogs.ConfirmationDialog
import helium314.keyboard.settings.keyboardNeedsReload

View file

@ -32,11 +32,11 @@ import helium314.keyboard.latin.utils.defaultToolbarPref
import helium314.keyboard.settings.AllPrefs
import helium314.keyboard.settings.NonSettingsPrefs
import helium314.keyboard.settings.PrefDef
import helium314.keyboard.settings.Preference
import helium314.keyboard.settings.ReorderSwitchPreference
import helium314.keyboard.settings.preferences.Preference
import helium314.keyboard.settings.preferences.ReorderSwitchPreference
import helium314.keyboard.settings.SearchPrefScreen
import helium314.keyboard.settings.SettingsActivity2
import helium314.keyboard.settings.SwitchPreference
import helium314.keyboard.settings.preferences.SwitchPreference
import helium314.keyboard.settings.Theme
import helium314.keyboard.settings.dialogs.ToolbarKeysCustomizer
import helium314.keyboard.settings.keyboardNeedsReload