0
0
mirror of https://github.com/florisboard/florisboard.git synced 2024-09-19 19:42:20 +02:00

Merge pull request #2473 from florisboard/feat/addons-support

Add addons support
This commit is contained in:
Patrick Goldinger 2024-07-04 19:31:11 +02:00 committed by GitHub
commit c909d3ad7d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
29 changed files with 746 additions and 512 deletions

View File

@ -16,6 +16,8 @@ This includes, but is not exclusive to:
- Remove existing glide/swipe typing (see 0.5 milestone)
- Improvements in clipboard / emoji functionality (v0.4.0-beta01/beta02)
- Prepare project to have native code implemented in [Rust](https://www.rust-lang.org/) (v0.4.0-beta02)
- - Upgrade Settings UI to Material 3 (v0.4.0-beta03)
- Add support for importing extensions via system file handler APIs (relevant for Addons store) (v0.4.0-beta03)
Note that the previous versioning scheme has been dropped in favor of using a major.minor.patch versioning scheme, so versions like `0.3.16` are a thing of the past :)
@ -32,7 +34,6 @@ Note that the previous versioning scheme has been dropped in favor of using a ma
- RFC document with technical details will be released later
- Add Tablet mode / Optimizations for landscape input based on new keyboard layout engine
- Reimplementation of glide typing with the new layout engine and predictive text core
- Add support for importing extensions via system file handler APIs (relevant for Addons store)
- Add support for any remaining new features introduced with Android 13
## 0.6
@ -52,7 +53,6 @@ Note that the previous versioning scheme has been dropped in favor of using a ma
**Features that MAY be added (even in versions mentioned above) or dismissed**
- Upgrade Settings UI to Material 3
- Full on-board layout editor which allows users to create their own layouts without writing a JSON file
- Theme rework part II
- Adaptive themes v2

View File

@ -64,6 +64,8 @@ android {
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
buildConfigField("String", "BUILD_COMMIT_HASH", "\"${getGitCommitHash()}\"")
buildConfigField("String", "FLADDONS_API_VERSION", "\"v~draft2\"")
buildConfigField("String", "FLADDONS_STORE_URL", "\"fladdonstest.patrickgold.dev\"")
ksp {
arg("room.schemaLocation", "$projectDir/schemas")

View File

@ -84,10 +84,23 @@
android:roundIcon="@mipmap/floris_app_icon_round"
android:windowSoftInputMode="adjustResize"
android:theme="@style/FlorisAppTheme.Splash"
android:exported="false">
android:exported="true">
<intent-filter>
<data android:scheme="florisboard" android:host="app-ui"/>
</intent-filter>
<intent-filter android:label="Import Extension">
<action android:name="android.intent.action.VIEW"/>
<category android:name="android.intent.category.DEFAULT"/>
<data android:scheme="content"/>
<data android:mimeType="application/vnd.florisboard.extension+zip"/>
<data android:mimeType="application/octet-stream"/><!-- Firefox looking at you :eyes: -->
</intent-filter>
<intent-filter android:label="Import Extension">
<action android:name="android.intent.action.SEND"/>
<category android:name="android.intent.category.DEFAULT"/>
<data android:mimeType="application/vnd.florisboard.extension+zip"/>
<data android:mimeType="application/octet-stream"/><!-- Firefox looking at you :eyes: -->
</intent-filter>
</activity>
<!-- Using an activity alias to disable/enable the app icon in the launcher -->
@ -106,24 +119,6 @@
</intent-filter>
</activity-alias>
<!-- Import File Bridging Activity -->
<activity
android:name="dev.patrickgold.florisboard.app.ext.ImportFileActivity"
android:icon="@mipmap/floris_app_icon"
android:label="@string/settings__title"
android:launchMode="singleTask"
android:roundIcon="@mipmap/floris_app_icon_round"
android:windowSoftInputMode="adjustResize"
android:theme="@style/FlorisAppTheme"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.VIEW"/>
<category android:name="android.intent.category.DEFAULT"/>
<data android:scheme="*" android:host="*" android:pathPattern=".*\\.flex"/>
<data android:scheme="*" android:host="*" android:pathPattern=".*\\.xpi"/>
</intent-filter>
</activity>
<!-- Crash Dialog Activity -->
<activity
android:name="dev.patrickgold.florisboard.lib.crashutility.CrashDialogActivity"

View File

@ -40,7 +40,6 @@ import dev.patrickgold.florisboard.lib.devtools.Flog
import dev.patrickgold.florisboard.lib.devtools.LogTopic
import dev.patrickgold.florisboard.lib.devtools.flogError
import dev.patrickgold.florisboard.lib.ext.ExtensionManager
import dev.patrickgold.florisboard.lib.io.AssetManager
import dev.patrickgold.florisboard.lib.io.deleteContentsRecursively
import dev.patrickgold.jetpref.datastore.JetPref
import org.florisboard.lib.kotlin.tryOrNull
@ -67,7 +66,6 @@ class FlorisApplication : Application() {
private val prefs by florisPreferenceModel()
private val mainHandler by lazy { Handler(mainLooper) }
val assetManager = lazy { AssetManager(this) }
val cacheManager = lazy { CacheManager(this) }
val clipboardManager = lazy { ClipboardManager(this) }
val editorInstance = lazy { EditorInstance(this) }
@ -144,8 +142,6 @@ private tailrec fun Context.florisApplication(): FlorisApplication {
fun Context.appContext() = lazyOf(this.florisApplication())
fun Context.assetManager() = this.florisApplication().assetManager
fun Context.cacheManager() = this.florisApplication().cacheManager
fun Context.clipboardManager() = this.florisApplication().clipboardManager

View File

@ -642,19 +642,11 @@ class AppPrefs : PreferenceModel("florisboard-app-prefs") {
key = "theme__mode",
default = ThemeMode.FOLLOW_SYSTEM,
)
val dayThemeAdaptToApp = boolean(
key = "theme__day_theme_adapt_to_app",
default = false,
)
val dayThemeId = custom(
key = "theme__day_theme_id",
default = extCoreTheme("floris_day"),
serializer = ExtensionComponentName.Serializer,
)
val nightThemeAdaptToApp = boolean(
key = "theme__night_theme_adapt_to_app",
default = false,
)
val nightThemeId = custom(
key = "theme__night_theme_id",
default = extCoreTheme("floris_night"),

View File

@ -17,6 +17,7 @@
package dev.patrickgold.florisboard.app
import android.content.Context
import android.content.Intent
import android.content.res.Configuration
import android.os.Bundle
import androidx.activity.ComponentActivity
@ -24,11 +25,11 @@ import androidx.activity.compose.setContent
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
@ -41,7 +42,9 @@ import androidx.navigation.NavController
import androidx.navigation.compose.rememberNavController
import dev.patrickgold.florisboard.R
import dev.patrickgold.florisboard.app.apptheme.FlorisAppTheme
import dev.patrickgold.florisboard.app.ext.ExtensionImportScreenType
import dev.patrickgold.florisboard.app.setup.NotificationPermissionState
import dev.patrickgold.florisboard.cacheManager
import dev.patrickgold.florisboard.lib.FlorisLocale
import dev.patrickgold.florisboard.lib.android.AndroidVersion
import dev.patrickgold.florisboard.lib.android.hideAppIcon
@ -70,9 +73,11 @@ val LocalNavController = staticCompositionLocalOf<NavController> {
class FlorisAppActivity : ComponentActivity() {
private val prefs by florisPreferenceModel()
private val cacheManager by cacheManager()
private var appTheme by mutableStateOf(AppTheme.AUTO)
private var showAppIcon = true
private var resourcesContext by mutableStateOf(this as Context)
private var fileImportIntent by mutableStateOf<Intent?>(null)
override fun onCreate(savedInstanceState: Bundle?) {
// Splash screen should be installed before calling super.onCreate()
@ -110,14 +115,15 @@ class FlorisAppActivity : ComponentActivity() {
AppVersionUtils.updateVersionOnInstallAndLastUse(this, prefs)
setContent {
ProvideLocalizedResources(resourcesContext) {
FlorisAppTheme(theme = appTheme, isMaterialYouAware = prefs.advanced.useMaterialYou.observeAsState().value) {
val useMaterialYou by prefs.advanced.useMaterialYou.observeAsState()
FlorisAppTheme(theme = appTheme, isMaterialYouAware = useMaterialYou) {
Surface(color = MaterialTheme.colorScheme.background) {
//SystemUiApp()
AppContent()
}
}
}
}
onNewIntent(intent)
}
}
@ -135,6 +141,21 @@ class FlorisAppActivity : ComponentActivity() {
}
}
override fun onNewIntent(intent: Intent?) {
super.onNewIntent(intent)
setIntent(intent)
if (intent?.action == Intent.ACTION_VIEW && intent.data != null) {
fileImportIntent = intent
return
}
if (intent?.action == Intent.ACTION_SEND && intent.clipData != null) {
fileImportIntent = intent
return
}
fileImportIntent = null
}
@Composable
private fun AppContent() {
val navController = rememberNavController()
@ -167,6 +188,20 @@ class FlorisAppActivity : ComponentActivity() {
}
}
LaunchedEffect(fileImportIntent) {
val intent = fileImportIntent
if (intent != null) {
val data = if (intent.action == Intent.ACTION_VIEW) {
intent.data!!
} else {
intent.clipData!!.getItemAt(0).uri
}
val workspace = runCatching { cacheManager.readFromUriIntoCache(data) }.getOrNull()
navController.navigate(Routes.Ext.Import(ExtensionImportScreenType.EXT_ANY, workspace?.uuid))
}
fileImportIntent = null
}
SideEffect {
navController.setOnBackPressedDispatcher(this.onBackPressedDispatcher)
}

View File

@ -27,8 +27,11 @@ import dev.patrickgold.florisboard.app.devtools.DevtoolsScreen
import dev.patrickgold.florisboard.app.devtools.ExportDebugLogScreen
import dev.patrickgold.florisboard.app.ext.ExtensionEditScreen
import dev.patrickgold.florisboard.app.ext.ExtensionExportScreen
import dev.patrickgold.florisboard.app.ext.ExtensionHomeScreen
import dev.patrickgold.florisboard.app.ext.ExtensionImportScreen
import dev.patrickgold.florisboard.app.ext.ExtensionImportScreenType
import dev.patrickgold.florisboard.app.ext.ExtensionListScreen
import dev.patrickgold.florisboard.app.ext.ExtensionListScreenType
import dev.patrickgold.florisboard.app.ext.ExtensionViewScreen
import dev.patrickgold.florisboard.app.settings.HomeScreen
import dev.patrickgold.florisboard.app.settings.about.AboutScreen
@ -58,7 +61,7 @@ import dev.patrickgold.florisboard.app.settings.typing.TypingScreen
import dev.patrickgold.florisboard.app.setup.SetupScreen
import org.florisboard.lib.kotlin.curlyFormat
@Suppress("FunctionName")
@Suppress("FunctionName", "ConstPropertyName")
object Routes {
object Setup {
const val Screen = "setup"
@ -117,6 +120,14 @@ object Routes {
}
object Ext {
const val Home = "ext"
const val List = "ext/list/{type}?showUpdate={showUpdate}"
fun List(
type: ExtensionListScreenType,
showUpdate: Boolean
) = List.curlyFormat("type" to type.id, "showUpdate" to showUpdate)
const val Edit = "ext/edit/{id}?create={serial_type}"
fun Edit(id: String, serialType: String? = null): String {
return Edit.curlyFormat("id" to id, "serial_type" to (serialType ?: ""))
@ -209,12 +220,20 @@ object Routes {
}
composable(Devtools.ExportDebugLog) { ExportDebugLogScreen() }
composable(Ext.Home) { ExtensionHomeScreen() }
composable(Ext.List) { navBackStack ->
val type = navBackStack.arguments?.getString("type")?.let { typeId ->
ExtensionListScreenType.entries.firstOrNull { it.id == typeId }
} ?: error("unknown type")
val showUpdate = navBackStack.arguments?.getString("showUpdate")
ExtensionListScreen(type, showUpdate == "true")
}
composable(Ext.Edit) { navBackStack ->
val extensionId = navBackStack.arguments?.getString("id")
val serialType = navBackStack.arguments?.getString("serial_type")
ExtensionEditScreen(
id = extensionId.toString(),
createSerialType = serialType.takeIf { it != null && it.isNotBlank() },
createSerialType = serialType.takeIf { !it.isNullOrBlank() },
)
}
composable(Ext.Export) { navBackStack ->

View File

@ -0,0 +1,93 @@
package dev.patrickgold.florisboard.app.ext
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Shop
import androidx.compose.material.icons.outlined.FileDownload
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import dev.patrickgold.florisboard.R
import dev.patrickgold.florisboard.app.LocalNavController
import dev.patrickgold.florisboard.app.Routes
import dev.patrickgold.florisboard.lib.android.launchUrl
import dev.patrickgold.florisboard.lib.compose.FlorisOutlinedBox
import dev.patrickgold.florisboard.lib.compose.FlorisTextButton
import dev.patrickgold.florisboard.lib.compose.defaultFlorisOutlinedBox
import dev.patrickgold.florisboard.lib.compose.stringRes
import dev.patrickgold.florisboard.lib.ext.Extension
import dev.patrickgold.florisboard.lib.ext.generateUpdateUrl
import org.florisboard.lib.kotlin.curlyFormat
@Composable
fun UpdateBox(extensionIndex: List<Extension>) {
val context = LocalContext.current
FlorisOutlinedBox(
modifier = Modifier.defaultFlorisOutlinedBox(),
) {
Text(
modifier = Modifier.padding(start = 16.dp, top = 8.dp, end = 16.dp, bottom = 4.dp),
text = stringRes(id = R.string.ext__update_box__internet_permission_hint),
style = MaterialTheme.typography.bodySmall,
)
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 6.dp),
) {
FlorisTextButton(
onClick = {
context.launchUrl(extensionIndex.generateUpdateUrl(version = "v~draft2", host = "fladdonstest.patrickgold.dev"))
},
icon = Icons.Outlined.FileDownload,
text = stringRes(id = R.string.ext__update_box__search_for_updates)
)
Spacer(modifier = Modifier.weight(1f))
}
}
}
@Composable
fun AddonManagementReferenceBox(
type: ExtensionListScreenType
) {
val navController = LocalNavController.current
FlorisOutlinedBox(
modifier = Modifier.defaultFlorisOutlinedBox(),
title = stringRes(id = R.string.ext__addon_management_box__managing_placeholder).curlyFormat(
"extensions" to type.let { stringRes(id = it.titleResId).lowercase() }
)
) {
Text(
modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp),
text = stringRes(id = R.string.ext__addon_management_box__addon_manager_info),
style = MaterialTheme.typography.bodySmall,
)
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 6.dp),
) {
Spacer(modifier = Modifier.weight(1f))
FlorisTextButton(
onClick = {
val route = Routes.Ext.List(type, showUpdate = true)
navController.navigate(
route
)
},
icon = Icons.Default.Shop,
text = stringRes(id = R.string.ext__addon_management_box__go_to_page).curlyFormat(
"ext_home_title" to stringRes(type.titleResId),
),
)
}
}
}

View File

@ -0,0 +1,101 @@
package dev.patrickgold.florisboard.app.ext
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.Input
import androidx.compose.material.icons.filled.Keyboard
import androidx.compose.material.icons.filled.Language
import androidx.compose.material.icons.filled.Palette
import androidx.compose.material.icons.filled.Shop
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import dev.patrickgold.florisboard.BuildConfig
import dev.patrickgold.florisboard.R
import dev.patrickgold.florisboard.app.LocalNavController
import dev.patrickgold.florisboard.app.Routes
import dev.patrickgold.florisboard.extensionManager
import dev.patrickgold.florisboard.lib.android.launchUrl
import dev.patrickgold.florisboard.lib.compose.FlorisOutlinedBox
import dev.patrickgold.florisboard.lib.compose.FlorisScreen
import dev.patrickgold.florisboard.lib.compose.FlorisTextButton
import dev.patrickgold.florisboard.lib.compose.defaultFlorisOutlinedBox
import dev.patrickgold.florisboard.lib.compose.stringRes
import dev.patrickgold.jetpref.datastore.ui.Preference
import dev.patrickgold.jetpref.datastore.ui.PreferenceGroup
@Composable
fun ExtensionHomeScreen() = FlorisScreen {
title = stringRes(R.string.ext__home__title)
previewFieldVisible = false
val context = LocalContext.current
val navController = LocalNavController.current
val extensionManager by context.extensionManager()
val extensionIndex = extensionManager.combinedExtensionList()
content {
FlorisOutlinedBox(
modifier = Modifier.defaultFlorisOutlinedBox(),
) {
Text(
modifier = Modifier.padding(start = 16.dp, top = 8.dp, end = 16.dp, bottom = 4.dp),
text = stringRes(id = R.string.ext__home__info),
style = MaterialTheme.typography.bodySmall,
)
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 6.dp),
) {
FlorisTextButton(
onClick = {
context.launchUrl("https://${BuildConfig.FLADDONS_STORE_URL}/")
},
icon = Icons.Default.Shop,
text = stringRes(id = R.string.ext__home__visit_store),
)
Spacer(modifier = Modifier.weight(1f))
FlorisTextButton(
onClick = {
navController.navigate(Routes.Ext.Import(ExtensionImportScreenType.EXT_ANY, null))
},
icon = Icons.AutoMirrored.Filled.Input,
text = stringRes(R.string.action__import),
)
}
}
UpdateBox(extensionIndex = extensionIndex)
PreferenceGroup(title = stringRes(id = R.string.ext__home__visit_store)) {
Preference(
icon = Icons.Default.Palette,
title = stringRes(R.string.ext__list__ext_theme),
onClick = {
navController.navigate(Routes.Ext.List(ExtensionListScreenType.EXT_THEME,false))
},
)
Preference(
icon = Icons.Default.Keyboard,
title = stringRes(R.string.ext__list__ext_keyboard),
onClick = {
navController.navigate(Routes.Ext.List(ExtensionListScreenType.EXT_KEYBOARD,false))
},
)
Preference(
icon = Icons.Default.Language,
title = stringRes(R.string.ext__list__ext_languagepack),
onClick = {
navController.navigate(Routes.Ext.List(ExtensionListScreenType.EXT_LANGUAGEPACK,false))
},
)
}
}
}

View File

@ -36,7 +36,6 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@ -100,12 +99,6 @@ fun ExtensionImportScreen(type: ExtensionImportScreenType, initUuid: String?) =
val cacheManager by context.cacheManager()
val extensionManager by context.extensionManager()
val initWsUuid by rememberSaveable { mutableStateOf(initUuid) }
var importResult by remember {
val workspace = initWsUuid?.let { cacheManager.importer.getWorkspaceByUuid(it) }?.let { resultOk(it) }
mutableStateOf(workspace)
}
fun getSkipReason(fileInfo: CacheManager.FileInfo): Int {
return when {
!FileRegistry.matchesFileFilter(fileInfo, type.supportedFiles) -> {
@ -119,29 +112,37 @@ fun ExtensionImportScreen(type: ExtensionImportScreenType, initUuid: String?) =
NATIVE_NULLPTR.toInt()
}
}
fileInfo.mediaType == FileRegistry.FlexExtension.mediaType -> {
else -> { // ext == null
R.string.ext__import__file_skip_ext_corrupted
}
else -> {
NATIVE_NULLPTR.toInt()
}
}
}
fun Result<CacheManager.ImporterWorkspace>.mapSkipReasons(): Result<CacheManager.ImporterWorkspace> {
return this.map { workspace ->
workspace.inputFileInfos.forEach { fileInfo ->
fileInfo.skipReason = getSkipReason(fileInfo)
}
workspace
}
}
var importResult by remember(initUuid) {
val workspace = initUuid?.let { cacheManager.importer.getWorkspaceByUuid(it) }
?.let { resultOk(it) }
?.mapSkipReasons()
mutableStateOf(workspace)
}
val importLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.GetMultipleContents(),
onResult = { uriList ->
// If uri is null it indicates that the selection activity
// was cancelled (mostly by pressing the back button), so
// we don't display an error message here.
if (uriList.isNullOrEmpty()) return@rememberLauncherForActivityResult
if (uriList.isEmpty()) return@rememberLauncherForActivityResult
importResult?.getOrNull()?.close()
importResult = runCatching { cacheManager.readFromUriIntoCache(uriList) }.map { workspace ->
workspace.inputFileInfos.forEach { fileInfo ->
fileInfo.skipReason = getSkipReason(fileInfo)
}
workspace
}
importResult = runCatching { cacheManager.readFromUriIntoCache(uriList) }.mapSkipReasons()
},
)
@ -197,15 +198,17 @@ fun ExtensionImportScreen(type: ExtensionImportScreenType, initUuid: String?) =
}
content {
FlorisOutlinedButton(
onClick = {
importLauncher.launch("*/*")
},
modifier = Modifier
.padding(vertical = 16.dp)
.align(Alignment.CenterHorizontally),
text = stringRes(R.string.action__select_files),
)
if (initUuid == null) {
FlorisOutlinedButton(
onClick = {
importLauncher.launch("*/*")
},
modifier = Modifier
.padding(vertical = 16.dp)
.align(Alignment.CenterHorizontally),
text = stringRes(R.string.action__select_files),
)
}
val result = importResult
when {

View File

@ -1,5 +1,5 @@
/*
* Copyright (C) 2021 Patrick Goldinger
* Copyright (C) 2024 Patrick Goldinger
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@ -16,84 +16,135 @@
package dev.patrickgold.florisboard.app.ext
import androidx.annotation.StringRes
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.outlined.Info
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.ExtendedFloatingActionButton
import androidx.compose.material3.FloatingActionButtonDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.navigation.NavController
import dev.patrickgold.florisboard.R
import dev.patrickgold.florisboard.app.LocalNavController
import dev.patrickgold.florisboard.app.Routes
import dev.patrickgold.florisboard.extensionManager
import dev.patrickgold.florisboard.ime.theme.ThemeExtension
import dev.patrickgold.florisboard.lib.compose.FlorisOutlinedBox
import dev.patrickgold.florisboard.lib.compose.FlorisScreen
import dev.patrickgold.florisboard.lib.compose.FlorisTextButton
import dev.patrickgold.florisboard.lib.compose.defaultFlorisOutlinedBox
import dev.patrickgold.florisboard.lib.compose.stringRes
import dev.patrickgold.florisboard.lib.ext.ExtensionManager
import dev.patrickgold.florisboard.lib.observeAsNonNullState
enum class ExtensionListScreenType(
val id: String,
@StringRes val titleResId: Int,
val getExtensionIndex: (ExtensionManager) -> ExtensionManager.ExtensionIndex<*>,
val launchExtensionCreate: ((NavController) -> Unit)?,
) {
EXT_THEME(
id = "ext-theme",
titleResId = R.string.ext__list__ext_theme,
getExtensionIndex = { it.themes },
launchExtensionCreate = { it.navigate(Routes.Ext.Edit("null", ThemeExtension.SERIAL_TYPE)) },
),
EXT_KEYBOARD(
id = "ext-keyboard",
titleResId = R.string.ext__list__ext_keyboard,
getExtensionIndex = { it.keyboardExtensions },
launchExtensionCreate = null,//{ it.navigate(Routes.Ext.Edit("null", KeyboardExtension.SERIAL_TYPE)) },
),
EXT_LANGUAGEPACK(
id = "ext-languagepack",
titleResId = R.string.ext__list__ext_languagepack,
getExtensionIndex = { it.languagePacks },
launchExtensionCreate = null,//{ it.navigate(Routes.Ext.Edit("null", LanguagePackExtension.SERIAL_TYPE)) },
);
}
@Composable
fun ExtensionListScreen() = FlorisScreen {
title = stringRes(R.string.about__title)
fun ExtensionListScreen(type: ExtensionListScreenType, showUpdate: Boolean) = FlorisScreen {
title = stringRes(type.titleResId)
previewFieldVisible = false
/*val navController = LocalNavController.current
val context = LocalContext.current
val extensionManager = ExtensionManager.def
val navController = LocalNavController.current
val extensionManager by context.extensionManager()
val extensionIndex by type.getExtensionIndex(extensionManager).observeAsNonNullState()
Column(
verticalArrangement = Arrangement.Top,
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier
.fillMaxWidth()
.padding(top = 24.dp, bottom = 32.dp)
) {
FlorisAppIcon()
Text(
text = stringRes(R.string.floris_app_name),
fontSize = 24.sp,
fontWeight = FontWeight.SemiBold,
modifier = Modifier.padding(top = 16.dp),
)
}
Preference(
icon = R.drawable.ic_info,
title = stringRes(R.string.about__version__title),
summary = appVersion,
onClick = {
try {
val isImeSelected = InputMethodUtils.checkIsFlorisboardSelected(context)
if (isImeSelected) {
FlorisClipboardManager.getInstance().addNewPlaintext(appVersion)
} else {
val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
val clip = ClipData.newPlainText("Florisboard version", appVersion)
clipboard.setPrimaryClip(clip)
content {
if (showUpdate) {
UpdateBox(extensionIndex = extensionIndex)
}
for (ext in extensionIndex) {
FlorisOutlinedBox(
modifier = Modifier.defaultFlorisOutlinedBox(),
title = ext.meta.title,
subtitle = ext.meta.id,
) {
Text(
modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp),
text = ext.meta.description ?: "",
style = MaterialTheme.typography.bodySmall,
)
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 6.dp),
) {
FlorisTextButton(
onClick = {
navController.navigate(Routes.Ext.View(ext.meta.id))
},
icon = Icons.Outlined.Info,
text = stringRes(id = R.string.ext__list__view_details),//stringRes(R.string.action__add),
colors = ButtonDefaults.textButtonColors(),
)
Spacer(modifier = Modifier.weight(1f))
FlorisTextButton(
onClick = {
navController.navigate(Routes.Ext.Edit(ext.meta.id))
},
icon = Icons.Default.Edit,
text = stringRes(R.string.action__edit),
enabled = extensionManager.canDelete(ext),
)
}
Toast.makeText(context, R.string.about__version_copied__title, Toast.LENGTH_SHORT).show()
} catch (e: Throwable) {
Toast.makeText(
context, context.getString(R.string.about__version_copied__error, e.message), Toast.LENGTH_SHORT
).show()
}
},
)
Preference(
icon = R.drawable.ic_history,
title = stringRes(R.string.about__changelog__title),
summary = stringRes(R.string.about__changelog__summary),
onClick = { launchUrl(context, R.string.florisboard__changelog_url, arrayOf(BuildConfig.VERSION_NAME)) },
)
Preference(
icon = R.drawable.ic_code,
title = stringRes(R.string.about__repository__title),
summary = stringRes(R.string.about__repository__summary),
onClick = { launchUrl(context, R.string.florisboard__repo_url) },
)
Preference(
icon = R.drawable.ic_policy,
title = stringRes(R.string.about__privacy_policy__title),
summary = stringRes(R.string.about__privacy_policy__summary),
onClick = { launchUrl(context, R.string.florisboard__privacy_policy_url) },
)
Preference(
icon = R.drawable.ic_description,
title = stringRes(R.string.about__project_license__title),
summary = stringRes(R.string.about__project_license__summary, "license_name" to "Apache 2.0"),
onClick = { navController.navigate(Routes.Settings.ProjectLicense) },
)
Preference(
icon = R.drawable.ic_description,
title = stringRes(id = R.string.about__third_party_licenses__title),
summary = stringRes(id = R.string.about__third_party_licenses__summary),
onClick = { navController.navigate(Routes.Settings.ThirdPartyLicenses) },
)*/
}
}
if (type.launchExtensionCreate != null) {
floatingActionButton {
ExtendedFloatingActionButton(
icon = {
Icon(
imageVector = Icons.Default.Add,
contentDescription = stringRes(id = R.string.ext__editor__title_create_any),
)
},
text = {
Text(
text = stringRes(id = R.string.ext__editor__title_create_any),
)
},
shape = FloatingActionButtonDefaults.extendedFabShape,
onClick = { type.launchExtensionCreate.invoke(navController) },
)
}
}
}

View File

@ -18,10 +18,8 @@ package dev.patrickgold.florisboard.app.ext
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.ExperimentalMaterialApi
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Link
import androidx.compose.material.icons.filled.Mail
import androidx.compose.material.icons.outlined.Mail
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
@ -32,13 +30,11 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import dev.patrickgold.florisboard.R
import dev.patrickgold.florisboard.lib.android.launchUrl
import dev.patrickgold.florisboard.lib.compose.FlorisChip
import dev.patrickgold.florisboard.lib.ext.ExtensionMaintainer
import dev.patrickgold.jetpref.material.ui.JetPrefAlertDialog
@OptIn(ExperimentalMaterialApi::class)
@Composable
fun ExtensionMaintainerChip(
maintainer: ExtensionMaintainer,

View File

@ -1,27 +0,0 @@
/*
* Copyright (C) 2021 Patrick Goldinger
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package dev.patrickgold.florisboard.app.ext
import android.os.Bundle
import androidx.activity.ComponentActivity
class ImportFileActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val data = intent.data
}
}

View File

@ -18,14 +18,13 @@ package dev.patrickgold.florisboard.app.settings
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Adb
import androidx.compose.material.icons.automirrored.outlined.Assignment
import androidx.compose.material.icons.filled.Extension
import androidx.compose.material.icons.filled.Gesture
import androidx.compose.material.icons.filled.Language
import androidx.compose.material.icons.filled.LibraryBooks
import androidx.compose.material.icons.filled.SentimentSatisfiedAlt
import androidx.compose.material.icons.filled.SmartButton
import androidx.compose.material.icons.filled.Spellcheck
import androidx.compose.material.icons.outlined.Assignment
import androidx.compose.material.icons.outlined.Build
import androidx.compose.material.icons.outlined.Info
import androidx.compose.material.icons.outlined.Keyboard
@ -131,18 +130,13 @@ fun HomeScreen() = FlorisScreen {
title = stringRes(R.string.settings__typing__title),
onClick = { navController.navigate(Routes.Settings.Typing) },
)
Preference(
icon = Icons.Default.LibraryBooks,
title = stringRes(R.string.settings__dictionary__title),
onClick = { navController.navigate(Routes.Settings.Dictionary) },
)
Preference(
icon = Icons.Default.Gesture,
title = stringRes(R.string.settings__gestures__title),
onClick = { navController.navigate(Routes.Settings.Gestures) },
)
Preference(
icon = Icons.Outlined.Assignment,
icon = Icons.AutoMirrored.Outlined.Assignment,
title = stringRes(R.string.settings__clipboard__title),
onClick = { navController.navigate(Routes.Settings.Clipboard) },
)
@ -152,9 +146,9 @@ fun HomeScreen() = FlorisScreen {
onClick = { navController.navigate(Routes.Settings.Media) },
)
Preference(
icon = Icons.Default.Adb,
title = stringRes(R.string.devtools__title),
onClick = { navController.navigate(Routes.Devtools.Home) },
icon = Icons.Default.Extension,
title = stringRes(R.string.ext__home__title),
onClick = { navController.navigate(Routes.Ext.Home) },
)
Preference(
icon = Icons.Outlined.Build,

View File

@ -28,12 +28,12 @@ import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.sp
import dev.patrickgold.florisboard.R
import dev.patrickgold.florisboard.assetManager
import dev.patrickgold.florisboard.lib.compose.FlorisScreen
import dev.patrickgold.florisboard.lib.compose.florisHorizontalScroll
import dev.patrickgold.florisboard.lib.compose.florisVerticalScroll
import dev.patrickgold.florisboard.lib.compose.stringRes
import dev.patrickgold.florisboard.lib.io.FlorisRef
import dev.patrickgold.florisboard.lib.io.loadTextAsset
@Composable
fun ProjectLicenseScreen() = FlorisScreen {
@ -41,7 +41,6 @@ fun ProjectLicenseScreen() = FlorisScreen {
scrollable = false
val context = LocalContext.current
val assetManager by context.assetManager()
content {
// Forcing LTR because the Apache 2.0 License shipped and displayed
@ -54,8 +53,8 @@ fun ProjectLicenseScreen() = FlorisScreen {
.florisVerticalScroll()
.florisHorizontalScroll(),
) {
val licenseText = assetManager.loadTextAsset(
FlorisRef.assets("license/project_license.txt")
val licenseText = FlorisRef.assets("license/project_license.txt").loadTextAsset(
context
).getOrElse {
stringRes(R.string.about__project_license__error_license_text_failed, "error_message" to (it.message ?: ""))
}

View File

@ -17,6 +17,7 @@
package dev.patrickgold.florisboard.app.settings.advanced
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Adb
import androidx.compose.material.icons.filled.Archive
import androidx.compose.material.icons.filled.FormatPaint
import androidx.compose.material.icons.filled.Language
@ -166,6 +167,11 @@ fun AdvancedScreen() = FlorisScreen {
title = stringRes(R.string.pref__advanced__incognito_mode__label),
entries = IncognitoMode.listEntries(),
)
Preference(
icon = Icons.Default.Adb,
title = stringRes(R.string.devtools__title),
onClick = { navController.navigate(Routes.Devtools.Home) },
)
PreferenceGroup(title = stringRes(R.string.backup_and_restore__title)) {
Preference(

View File

@ -16,83 +16,56 @@
package dev.patrickgold.florisboard.app.settings.theme
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.DarkMode
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.filled.Input
import androidx.compose.material.icons.filled.LightMode
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.RadioButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
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.compose.ui.unit.dp
import dev.patrickgold.florisboard.R
import dev.patrickgold.florisboard.app.LocalNavController
import dev.patrickgold.florisboard.app.Routes
import dev.patrickgold.florisboard.app.ext.ExtensionImportScreenType
import dev.patrickgold.florisboard.app.florisPreferenceModel
import dev.patrickgold.florisboard.extensionManager
import dev.patrickgold.florisboard.ime.theme.ThemeExtension
import dev.patrickgold.florisboard.ime.theme.ThemeExtensionComponent
import dev.patrickgold.florisboard.lib.android.showLongToast
import dev.patrickgold.florisboard.lib.compose.FlorisConfirmDeleteDialog
import dev.patrickgold.florisboard.lib.compose.FlorisOutlinedBox
import dev.patrickgold.florisboard.lib.compose.FlorisScreen
import dev.patrickgold.florisboard.lib.compose.FlorisTextButton
import dev.patrickgold.florisboard.lib.compose.defaultFlorisOutlinedBox
import dev.patrickgold.florisboard.lib.compose.rippleClickable
import dev.patrickgold.florisboard.lib.compose.stringRes
import dev.patrickgold.florisboard.lib.ext.Extension
import dev.patrickgold.florisboard.lib.ext.ExtensionComponentName
import dev.patrickgold.florisboard.lib.observeAsNonNullState
import dev.patrickgold.florisboard.themeManager
import dev.patrickgold.jetpref.datastore.model.observeAsState
import dev.patrickgold.jetpref.datastore.ui.ExperimentalJetPrefDatastoreUi
import dev.patrickgold.jetpref.datastore.ui.Preference
import dev.patrickgold.jetpref.material.ui.JetPrefListItem
enum class ThemeManagerScreenAction(val id: String) {
SELECT_DAY("select-day"),
SELECT_NIGHT("select-night"),
MANAGE("manage-installed-themes");
SELECT_NIGHT("select-night");
}
@OptIn(ExperimentalJetPrefDatastoreUi::class)
@Composable
fun ThemeManagerScreen(action: ThemeManagerScreenAction?) = FlorisScreen {
title = stringRes(when (action) {
ThemeManagerScreenAction.SELECT_DAY -> R.string.settings__theme_manager__title_day
ThemeManagerScreenAction.SELECT_NIGHT -> R.string.settings__theme_manager__title_night
ThemeManagerScreenAction.MANAGE -> R.string.settings__theme_manager__title_manage
else -> error("Theme manager screen action must not be null")
})
previewFieldVisible = action != ThemeManagerScreenAction.MANAGE
previewFieldVisible = true
val prefs by florisPreferenceModel()
val navController = LocalNavController.current
val context = LocalContext.current
val extensionManager by context.extensionManager()
val themeManager by context.themeManager()
val indexedThemeExtensions by extensionManager.themes.observeAsNonNullState()
val selectedManagerThemeId = remember { mutableStateOf<ExtensionComponentName?>(null) }
val extGroupedThemes = remember(indexedThemeExtensions) {
buildMap<String, List<ThemeExtensionComponent>> {
for (ext in indexedThemeExtensions) {
@ -104,7 +77,6 @@ fun ThemeManagerScreen(action: ThemeManagerScreenAction?) = FlorisScreen {
fun getThemeIdPref() = when (action) {
ThemeManagerScreenAction.SELECT_DAY -> prefs.theme.dayThemeId
ThemeManagerScreenAction.SELECT_NIGHT -> prefs.theme.nightThemeId
ThemeManagerScreenAction.MANAGE -> error("internal error in manager logic")
}
fun setTheme(extId: String, componentId: String) {
@ -114,18 +86,13 @@ fun ThemeManagerScreen(action: ThemeManagerScreenAction?) = FlorisScreen {
ThemeManagerScreenAction.SELECT_NIGHT -> {
getThemeIdPref().set(extComponentName)
}
ThemeManagerScreenAction.MANAGE -> {
selectedManagerThemeId.value = extComponentName
}
}
}
val activeThemeId by when (action) {
ThemeManagerScreenAction.SELECT_DAY,
ThemeManagerScreenAction.SELECT_NIGHT -> getThemeIdPref().observeAsState()
ThemeManagerScreenAction.MANAGE -> selectedManagerThemeId
}
var themeExtToDelete by remember { mutableStateOf<Extension?>(null) }
content {
DisposableEffect(activeThemeId) {
@ -135,34 +102,12 @@ fun ThemeManagerScreen(action: ThemeManagerScreenAction?) = FlorisScreen {
}
}
val grayColor = LocalContentColor.current.copy(alpha = 0.56f)
if (action == ThemeManagerScreenAction.MANAGE) {
FlorisOutlinedBox(
modifier = Modifier.defaultFlorisOutlinedBox(),
) {
this@content.Preference(
onClick = { navController.navigate(
Routes.Ext.Edit("null", ThemeExtension.SERIAL_TYPE)
) },
icon = Icons.Default.Add,
title = stringRes(R.string.ext__editor__title_create_theme),
)
this@content.Preference(
onClick = { navController.navigate(
Routes.Ext.Import(ExtensionImportScreenType.EXT_THEME, null)
) },
icon = Icons.Default.Input,
title = stringRes(R.string.action__import),
)
}
}
for ((extensionId, configs) in extGroupedThemes) key(extensionId) {
val ext = extensionManager.getExtensionById(extensionId)!!
FlorisOutlinedBox(
modifier = Modifier.defaultFlorisOutlinedBox(),
title = ext.meta.title,
onTitleClick = { navController.navigate(Routes.Ext.View(extensionId)) },
subtitle = extensionId,
onSubtitleClick = { navController.navigate(Routes.Ext.View(extensionId)) },
) {
for (config in configs) key(extensionId, config.id) {
JetPrefListItem(
@ -171,8 +116,8 @@ fun ThemeManagerScreen(action: ThemeManagerScreenAction?) = FlorisScreen {
},
icon = {
RadioButton(
selected = activeThemeId?.extensionId == extensionId &&
activeThemeId?.componentId == config.id,
selected = activeThemeId.extensionId == extensionId &&
activeThemeId.componentId == config.id,
onClick = null,
)
},
@ -191,51 +136,7 @@ fun ThemeManagerScreen(action: ThemeManagerScreenAction?) = FlorisScreen {
},
)
}
if (action == ThemeManagerScreenAction.MANAGE && extensionManager.canDelete(ext)) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 6.dp),
) {
FlorisTextButton(
onClick = {
themeExtToDelete = ext
},
icon = Icons.Default.Delete,
text = stringRes(R.string.action__delete),
colors = ButtonDefaults.textButtonColors(
contentColor = MaterialTheme.colorScheme.error,
),
)
Spacer(modifier = Modifier.weight(1f))
FlorisTextButton(
onClick = {
navController.navigate(Routes.Ext.Edit(ext.meta.id))
},
icon = Icons.Default.Edit,
text = stringRes(R.string.action__edit),
)
}
}
}
}
if (themeExtToDelete != null) {
FlorisConfirmDeleteDialog(
onConfirm = {
runCatching {
extensionManager.delete(themeExtToDelete!!)
}.onFailure { error ->
context.showLongToast(
R.string.error__snackbar_message,
"error_message" to error.localizedMessage,
)
}
themeExtToDelete = null
},
onDismiss = { themeExtToDelete = null },
what = themeExtToDelete!!.meta.title,
)
}
}
}

View File

@ -16,35 +16,32 @@
package dev.patrickgold.florisboard.app.settings.theme
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.BrightnessAuto
import androidx.compose.material.icons.filled.DarkMode
import androidx.compose.material.icons.filled.FormatPaint
import androidx.compose.material.icons.filled.LightMode
import androidx.compose.material.icons.outlined.Palette
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import dev.patrickgold.florisboard.R
import dev.patrickgold.florisboard.app.LocalNavController
import dev.patrickgold.florisboard.app.Routes
import dev.patrickgold.florisboard.app.ext.AddonManagementReferenceBox
import dev.patrickgold.florisboard.app.ext.ExtensionListScreenType
import dev.patrickgold.florisboard.ime.theme.ThemeManager
import dev.patrickgold.florisboard.ime.theme.ThemeMode
import dev.patrickgold.florisboard.lib.android.launchUrl
import dev.patrickgold.florisboard.lib.compose.FlorisInfoCard
import dev.patrickgold.florisboard.lib.compose.FlorisScreen
import dev.patrickgold.florisboard.lib.compose.stringRes
import dev.patrickgold.florisboard.lib.ext.ExtensionComponentName
import dev.patrickgold.florisboard.themeManager
import dev.patrickgold.jetpref.datastore.model.observeAsState
import dev.patrickgold.jetpref.datastore.ui.ListPreference
import dev.patrickgold.jetpref.datastore.ui.Preference
import dev.patrickgold.jetpref.datastore.ui.PreferenceGroup
import dev.patrickgold.jetpref.datastore.ui.SwitchPreference
@Composable
fun ThemeScreen() = FlorisScreen {
@ -53,13 +50,21 @@ fun ThemeScreen() = FlorisScreen {
val context = LocalContext.current
val navController = LocalNavController.current
val themeManager by context.themeManager()
@Composable
fun ThemeManager.getThemeLabel(id: ExtensionComponentName): String {
val configs by indexedThemeConfigs.observeAsState()
configs?.get(id)?.let { return it.label }
return id.toString()
}
content {
val themeMode by prefs.theme.mode.observeAsState()
val dayThemeId by prefs.theme.dayThemeId.observeAsState()
val nightThemeId by prefs.theme.nightThemeId.observeAsState()
Card(modifier = Modifier.padding(8.dp)) {
/*Card(modifier = Modifier.padding(8.dp)) {
Column(modifier = Modifier.padding(8.dp)) {
Text("If you want to give feedback on the new stylesheet editor and theme engine, please do so in below linked feedback thread:\n")
Button(onClick = {
@ -68,7 +73,7 @@ fun ThemeScreen() = FlorisScreen {
Text("Open Feedback Thread")
}
}
}
}*/
ListPreference(
prefs.theme.mode,
@ -85,53 +90,24 @@ fun ThemeScreen() = FlorisScreen {
)
}
Preference(
icon = Icons.Outlined.Palette,
title = stringRes(R.string.settings__theme_manager__title_manage),
icon = Icons.Default.LightMode,
title = stringRes(R.string.pref__theme__day),
summary = themeManager.getThemeLabel(dayThemeId),
enabledIf = { prefs.theme.mode isNotEqualTo ThemeMode.ALWAYS_NIGHT },
onClick = {
navController.navigate(Routes.Settings.ThemeManager(ThemeManagerScreenAction.MANAGE))
navController.navigate(Routes.Settings.ThemeManager(ThemeManagerScreenAction.SELECT_DAY))
},
)
Preference(
icon = Icons.Default.DarkMode,
title = stringRes(R.string.pref__theme__night),
summary = themeManager.getThemeLabel(nightThemeId),
enabledIf = { prefs.theme.mode isNotEqualTo ThemeMode.ALWAYS_DAY },
onClick = {
navController.navigate(Routes.Settings.ThemeManager(ThemeManagerScreenAction.SELECT_NIGHT))
},
)
PreferenceGroup(
title = stringRes(R.string.pref__theme__day),
enabledIf = { prefs.theme.mode isNotEqualTo ThemeMode.ALWAYS_NIGHT },
) {
Preference(
icon = Icons.Default.LightMode,
title = stringRes(R.string.pref__theme__any_theme__label),
summary = dayThemeId.toString(),
onClick = {
navController.navigate(Routes.Settings.ThemeManager(ThemeManagerScreenAction.SELECT_DAY))
},
)
SwitchPreference(
prefs.theme.dayThemeAdaptToApp,
icon = Icons.Default.FormatPaint,
title = stringRes(R.string.pref__theme__any_theme_adapt_to_app__label),
summary = stringRes(R.string.pref__theme__any_theme_adapt_to_app__summary),
visibleIf = { false },
)
}
PreferenceGroup(
title = stringRes(R.string.pref__theme__night),
enabledIf = { prefs.theme.mode isNotEqualTo ThemeMode.ALWAYS_DAY },
) {
Preference(
icon = Icons.Default.DarkMode,
title = stringRes(R.string.pref__theme__any_theme__label),
summary = nightThemeId.toString(),
onClick = {
navController.navigate(Routes.Settings.ThemeManager(ThemeManagerScreenAction.SELECT_NIGHT))
},
)
SwitchPreference(
prefs.theme.nightThemeAdaptToApp,
icon = Icons.Default.FormatPaint,
title = stringRes(R.string.pref__theme__any_theme_adapt_to_app__label),
summary = stringRes(R.string.pref__theme__any_theme_adapt_to_app__summary),
visibleIf = { false },
)
}
AddonManagementReferenceBox(type = ExtensionListScreenType.EXT_THEME)
}
}

View File

@ -19,9 +19,9 @@ package dev.patrickgold.florisboard.app.settings.typing
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.LibraryBooks
import androidx.compose.material.icons.filled.Contacts
import androidx.compose.material.icons.filled.Language
import androidx.compose.material.icons.filled.LibraryBooks
import androidx.compose.material.icons.filled.SpaceBar
import androidx.compose.material3.Card
import androidx.compose.material3.Text
@ -32,6 +32,8 @@ import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import dev.patrickgold.florisboard.R
import dev.patrickgold.florisboard.app.LocalNavController
import dev.patrickgold.florisboard.app.Routes
import dev.patrickgold.florisboard.ime.nlp.SpellingLanguageMode
import dev.patrickgold.florisboard.lib.android.AndroidVersion
import dev.patrickgold.florisboard.lib.compose.FlorisErrorCard
@ -42,6 +44,7 @@ import dev.patrickgold.jetpref.datastore.model.observeAsState
import dev.patrickgold.jetpref.datastore.ui.DialogSliderPreference
import dev.patrickgold.jetpref.datastore.ui.ExperimentalJetPrefDatastoreUi
import dev.patrickgold.jetpref.datastore.ui.ListPreference
import dev.patrickgold.jetpref.datastore.ui.Preference
import dev.patrickgold.jetpref.datastore.ui.PreferenceGroup
import dev.patrickgold.jetpref.datastore.ui.SwitchPreference
@ -51,6 +54,8 @@ fun TypingScreen() = FlorisScreen {
title = stringRes(R.string.settings__typing__title)
previewFieldVisible = true
val navController = LocalNavController.current
content {
// This card is temporary and is therefore not using a string resource
FlorisErrorCard(
@ -159,12 +164,20 @@ fun TypingScreen() = FlorisScreen {
)
SwitchPreference(
prefs.spelling.useUdmEntries,
icon = Icons.Default.LibraryBooks,
icon = Icons.AutoMirrored.Filled.LibraryBooks,
title = stringRes(R.string.pref__spelling__use_udm_entries__label),
summary = stringRes(R.string.pref__spelling__use_udm_entries__summary),
enabledIf = { florisSpellCheckerEnabled.value },
visibleIf = { false }, // For now
)
}
PreferenceGroup(title = stringRes(R.string.settings__dictionary__title)) {
Preference(
icon = Icons.AutoMirrored.Filled.LibraryBooks,
title = stringRes(R.string.settings__dictionary__title),
onClick = { navController.navigate(Routes.Settings.Dictionary) },
)
}
}
}

View File

@ -42,12 +42,10 @@ import androidx.compose.material.icons.filled.SelectAll
import androidx.compose.material.icons.filled.Send
import androidx.compose.material.icons.filled.SentimentSatisfiedAlt
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material.icons.filled.Smartphone
import androidx.compose.material.icons.filled.SpaceBar
import androidx.compose.material.icons.filled.Undo
import androidx.compose.material.icons.outlined.Assignment
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.vectorResource
import dev.patrickgold.florisboard.R
import dev.patrickgold.florisboard.ime.core.DisplayLanguageNamesIn
import dev.patrickgold.florisboard.ime.core.Subtype
@ -57,6 +55,7 @@ import dev.patrickgold.florisboard.ime.input.InputShiftState
import dev.patrickgold.florisboard.ime.text.key.KeyCode
import dev.patrickgold.florisboard.ime.text.key.KeyType
import dev.patrickgold.florisboard.lib.FlorisLocale
import dev.patrickgold.florisboard.lib.android.AndroidInternalR
import dev.patrickgold.jetpref.datastore.ui.vectorResource
interface ComputingEvaluator {
@ -209,8 +208,7 @@ fun ComputingEvaluator.computeImageVector(data: KeyData): ImageVector? {
}
KeyCode.COMPACT_LAYOUT_TO_LEFT,
KeyCode.COMPACT_LAYOUT_TO_RIGHT -> {
// TODO: find a better icon for compact mode
Icons.Default.Smartphone
context()?.vectorResource(id = AndroidInternalR.drawable.ic_qs_one_handed_mode)
}
KeyCode.VOICE_INPUT -> {
Icons.Default.KeyboardVoice
@ -276,9 +274,9 @@ fun ComputingEvaluator.computeImageVector(data: KeyData): ImageVector? {
}
KeyCode.TOGGLE_INCOGNITO_MODE -> {
if (evaluator.state.isIncognitoMode) {
ImageVector.vectorResource(theme = null, resId = R.drawable.ic_incognito, res = this.context()?.resources!!)
this.context()?.vectorResource(id = R.drawable.ic_incognito)
} else {
ImageVector.vectorResource(theme = null, resId = R.drawable.ic_incognito_off, res = this.context()?.resources!!)
this.context()?.vectorResource(id = R.drawable.ic_incognito_off)
}
}
KeyCode.TOGGLE_AUTOCORRECT -> {

View File

@ -19,7 +19,6 @@ package dev.patrickgold.florisboard.ime.keyboard
import android.content.Context
import dev.patrickgold.florisboard.app.florisPreferenceModel
import dev.patrickgold.florisboard.appContext
import dev.patrickgold.florisboard.assetManager
import dev.patrickgold.florisboard.extensionManager
import dev.patrickgold.florisboard.ime.core.Subtype
import dev.patrickgold.florisboard.ime.popup.PopupMapping
@ -34,6 +33,7 @@ import dev.patrickgold.florisboard.lib.devtools.flogDebug
import dev.patrickgold.florisboard.lib.devtools.flogWarning
import dev.patrickgold.florisboard.lib.ext.ExtensionComponentName
import dev.patrickgold.florisboard.lib.io.ZipUtils
import dev.patrickgold.florisboard.lib.io.loadJsonAsset
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Deferred
import kotlinx.coroutines.Dispatchers
@ -69,7 +69,6 @@ private data class CachedPopupMapping(
class LayoutManager(context: Context) {
private val prefs by florisPreferenceModel()
private val appContext by context.appContext()
private val assetManager by context.assetManager()
private val extensionManager by context.extensionManager()
private val keyboardManager by context.keyboardManager()
@ -101,7 +100,7 @@ class LayoutManager(context: Context) {
val layout = async {
runCatching {
val jsonStr = ZipUtils.readFileFromArchive(appContext, ext.sourceRef!!, path).getOrThrow()
val arrangement = assetManager.loadJsonAsset<LayoutArrangement>(jsonStr).getOrThrow()
val arrangement = loadJsonAsset<LayoutArrangement>(jsonStr).getOrThrow()
CachedLayout(ltn.type, ltn.name, meta, arrangement)
}
}
@ -128,7 +127,7 @@ class LayoutManager(context: Context) {
val popupMapping = async {
runCatching {
val jsonStr = ZipUtils.readFileFromArchive(appContext, ext.sourceRef!!, path).getOrThrow()
val mapping = assetManager.loadJsonAsset<PopupMapping>(jsonStr).getOrThrow()
val mapping = loadJsonAsset<PopupMapping>(jsonStr).getOrThrow()
CachedPopupMapping(name, meta, mapping)
}
}

View File

@ -51,4 +51,11 @@ object AndroidInternalR {
Resources.getSystem().getIdentifier("ime_action_default", "string", "android")
}
}
@SuppressLint("DiscouragedApi")
@Suppress("ClassName")
object drawable {
val ic_qs_one_handed_mode by lazy {
Resources.getSystem().getIdentifier("ic_qs_one_handed_mode", "drawable", "android")
}
}
}

View File

@ -17,6 +17,8 @@
package dev.patrickgold.florisboard.lib.ext
import android.content.Context
import android.net.Uri
import dev.patrickgold.florisboard.BuildConfig
import dev.patrickgold.florisboard.lib.io.FlorisRef
import dev.patrickgold.florisboard.lib.io.FsDir
import dev.patrickgold.florisboard.lib.io.FsFile
@ -113,6 +115,37 @@ abstract class Extension {
abstract fun edit(): ExtensionEditor
}
/**
* Generates an update url for [Extension] lists.
*
* @param version the version of the api path
* @param host the host for the addons store
* @return the Url
*/
internal fun List<Extension>.generateUpdateUrl(
version: String = BuildConfig.FLADDONS_API_VERSION,
host: String = BuildConfig.FLADDONS_STORE_URL,
): String {
return Uri.Builder().run {
scheme("https")
authority(host)
appendPath("updates")
appendPath(version)
encodedFragment(
buildString {
append("data={")
for (extension in this@generateUpdateUrl) {
append(extension.meta.getUpdateJsonPair())
if (extension != this@generateUpdateUrl.last()) {
append(",")
}
}
append("}")
}
)
}.build().toString()
}
interface ExtensionEditor {
var meta: ExtensionMeta
val dependencies: MutableList<String>

View File

@ -19,9 +19,9 @@ package dev.patrickgold.florisboard.lib.ext
import android.content.Context
import android.net.Uri
import android.os.FileObserver
import androidx.compose.runtime.Composable
import androidx.lifecycle.LiveData
import dev.patrickgold.florisboard.appContext
import dev.patrickgold.florisboard.assetManager
import dev.patrickgold.florisboard.ime.keyboard.KeyboardExtension
import dev.patrickgold.florisboard.ime.nlp.LanguagePackExtension
import dev.patrickgold.florisboard.ime.text.composing.Appender
@ -37,7 +37,12 @@ import dev.patrickgold.florisboard.lib.devtools.flogError
import dev.patrickgold.florisboard.lib.io.FlorisRef
import dev.patrickgold.florisboard.lib.io.FsFile
import dev.patrickgold.florisboard.lib.io.ZipUtils
import dev.patrickgold.florisboard.lib.io.delete
import dev.patrickgold.florisboard.lib.io.listDirs
import dev.patrickgold.florisboard.lib.io.listFiles
import dev.patrickgold.florisboard.lib.io.loadJsonAsset
import dev.patrickgold.florisboard.lib.io.writeJson
import dev.patrickgold.florisboard.lib.observeAsNonNullState
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@ -86,13 +91,17 @@ class ExtensionManager(context: Context) {
}
private val appContext by context.appContext()
private val assetManager by context.assetManager()
private val ioScope = CoroutineScope(Dispatchers.IO)
val keyboardExtensions = ExtensionIndex(KeyboardExtension.serializer(), IME_KEYBOARD_PATH)
val themes = ExtensionIndex(ThemeExtension.serializer(), IME_THEME_PATH)
val languagePacks = ExtensionIndex(LanguagePackExtension.serializer(), IME_LANGUAGEPACK_PATH)
@Composable
fun combinedExtensionList() = listOf(keyboardExtensions.observeAsNonNullState(), themes.observeAsNonNullState(), languagePacks.observeAsNonNullState()).map {
it.value
}.flatten()
fun init() {
keyboardExtensions.init()
themes.init()
@ -142,7 +151,7 @@ class ExtensionManager(context: Context) {
fun delete(ext: Extension) {
check(canDelete(ext)) { "Cannot delete extension!" }
ext.unload(appContext)
assetManager.delete(ext.sourceRef!!)
ext.sourceRef!!.delete(appContext)
}
inner class ExtensionIndex<T : Extension>(
@ -200,11 +209,11 @@ class ExtensionManager(context: Context) {
private fun indexAssetsModule(): List<T> {
val list = mutableListOf<T>()
assetManager.listDirs(assetsModuleRef).fold(
assetsModuleRef.listDirs(appContext).fold(
onSuccess = { extRefs ->
for (extRef in extRefs) {
val fileRef = extRef.subRef(ExtensionDefaults.MANIFEST_FILE_NAME)
assetManager.loadJsonAsset(fileRef, serializer, ExtensionJsonConfig).fold(
fileRef.loadJsonAsset(appContext, serializer, ExtensionJsonConfig).fold(
onSuccess = { ext ->
ext.sourceRef = extRef
list.add(ext)
@ -224,7 +233,7 @@ class ExtensionManager(context: Context) {
private fun indexInternalModule(): List<T> {
val list = mutableListOf<T>()
assetManager.listFiles(internalModuleRef).fold(
internalModuleRef.listFiles(appContext).fold(
onSuccess = { extRefs ->
for (extRef in extRefs) {
val fileRef = extRef.absoluteFile(appContext)
@ -233,7 +242,7 @@ class ExtensionManager(context: Context) {
}
ZipUtils.readFileFromArchive(appContext, extRef, ExtensionDefaults.MANIFEST_FILE_NAME).fold(
onSuccess = { metaStr ->
assetManager.loadJsonAsset(metaStr, serializer, ExtensionJsonConfig).fold(
loadJsonAsset(metaStr, serializer, ExtensionJsonConfig).fold(
onSuccess = { ext ->
ext.sourceRef = extRef
list.add(ext)

View File

@ -101,4 +101,8 @@ data class ExtensionMeta(
* Use an SPDX license expression if this extension has multiple licenses.
*/
val license: String,
)
) {
fun getUpdateJsonPair(): String {
return "\"${id}\":\"${version}\""
}
}

View File

@ -17,6 +17,7 @@
package dev.patrickgold.florisboard.lib.ext
import androidx.core.text.trimmedLength
import dev.patrickgold.florisboard.R
import dev.patrickgold.florisboard.ime.theme.ThemeExtensionComponent
import dev.patrickgold.florisboard.lib.ValidationRule
import dev.patrickgold.florisboard.lib.snygg.SnyggStylesheet
@ -25,8 +26,6 @@ import dev.patrickgold.florisboard.lib.snygg.value.SnyggPercentShapeValue
import dev.patrickgold.florisboard.lib.snygg.value.SnyggSolidColorValue
import dev.patrickgold.florisboard.lib.validate
// TODO: (priority=medium)
// make all strings available for localize
object ExtensionValidation {
private val MetaIdRegex = """^[a-z][a-z0-9_]*(\.[a-z0-9][a-z0-9_]*)*${'$'}""".toRegex()
private val ComponentIdRegex = """^[a-z][a-z0-9_]*${'$'}""".toRegex()
@ -38,9 +37,9 @@ object ExtensionValidation {
forProperty = "id"
validator { str ->
when {
str.isBlank() -> resultInvalid(error = "Please enter a package name")
str.isBlank() -> resultInvalid(error = R.string.ext__validation__enter_package_name)
MetaIdRegex.matches(str) -> resultValid()
else -> resultInvalid("Package name does not match regex $MetaIdRegex")
else -> resultInvalid(error = R.string.ext__validation__error_package_name, "id_regex" to MetaIdRegex)
}
}
}
@ -50,7 +49,7 @@ object ExtensionValidation {
forProperty = "version"
validator { str ->
when {
str.isBlank() -> resultInvalid(error = "Please enter a version")
str.isBlank() -> resultInvalid(error = R.string.ext__validation__enter_version)
else -> resultValid()
}
}
@ -61,7 +60,7 @@ object ExtensionValidation {
forProperty = "title"
validator { str ->
when {
str.isBlank() -> resultInvalid(error = "Please enter a title")
str.isBlank() -> resultInvalid(error = R.string.ext__validation__enter_title)
else -> resultValid()
}
}
@ -73,7 +72,7 @@ object ExtensionValidation {
validator { str ->
val maintainers = str.lines().filter { it.isNotBlank() }
when {
maintainers.isEmpty() -> resultInvalid(error = "Please enter at least one valid maintainer")
maintainers.isEmpty() -> resultInvalid(error = R.string.ext__validation__enter_maintainer)
else -> resultValid()
}
}
@ -84,7 +83,7 @@ object ExtensionValidation {
forProperty = "license"
validator { str ->
when {
str.isBlank() -> resultInvalid(error = "Please enter a license identifier")
str.isBlank() -> resultInvalid(error = R.string.ext__validation__enter_license)
else -> resultValid()
}
}
@ -95,8 +94,8 @@ object ExtensionValidation {
forProperty = "id"
validator { str ->
when {
str.isBlank() -> resultInvalid(error = "Please enter a component ID")
!ComponentIdRegex.matches(str) -> resultInvalid(error = "Please enter a component ID matching $ComponentIdRegex")
str.isBlank() -> resultInvalid(error = R.string.ext__validation__enter_component_id)
!ComponentIdRegex.matches(str) -> resultInvalid(error = R.string.ext__validation__error_component_id, "component_id_regex" to ComponentIdRegex)
else -> resultValid()
}
}
@ -107,8 +106,8 @@ object ExtensionValidation {
forProperty = "label"
validator { str ->
when {
str.isBlank() -> resultInvalid(error = "Please enter a component label")
str.trimmedLength() > 30 -> resultValid(hint = "Your component label is quite long, which may lead to clipping in the UI")
str.isBlank() -> resultInvalid(error = R.string.ext__validation__enter_component_label)
str.trimmedLength() > 30 -> resultValid(hint = R.string.ext__validation__hint_component_label_to_long)
else -> resultValid()
}
}
@ -120,7 +119,7 @@ object ExtensionValidation {
validator { str ->
val authors = str.lines().filter { it.isNotBlank() }
when {
authors.isEmpty() -> resultInvalid(error = "Please enter at least one valid author")
authors.isEmpty() -> resultInvalid(error = R.string.ext__validation__error_author)
else -> resultValid()
}
}
@ -132,9 +131,9 @@ object ExtensionValidation {
validator { str ->
when {
str.isEmpty() -> resultValid()
str.isBlank() -> resultInvalid(error = "The stylesheet path must not be blank")
str.isBlank() -> resultInvalid(error = R.string.ext__validation__error_stylesheet_path_blank)
!ThemeComponentStylesheetPathRegex.matches(str) -> {
resultInvalid(error = "Please enter a valid stylesheet path matching $ThemeComponentStylesheetPathRegex")
resultInvalid(error = R.string.ext__validation__error_stylesheet_path, "stylesheet_path_regex" to ThemeComponentStylesheetPathRegex)
}
else -> resultValid()
}
@ -147,12 +146,12 @@ object ExtensionValidation {
validator { input ->
val str = input.trim()
when {
str.isBlank() -> resultInvalid(error = "Please enter a variable name")
str.isBlank() -> resultInvalid(error = R.string.ext__validation__enter_property)
str == "-" || str.startsWith("--") -> resultValid()
!ThemeComponentVariableNameRegex.matches(str) -> {
resultInvalid(error = "Please enter a valid variable name matching $ThemeComponentVariableNameRegex")
resultInvalid(error = R.string.ext__validation__error_property, "variable_name_regex" to ThemeComponentVariableNameRegex)
}
else -> resultValid(hint = "By convention a FlorisCSS variable name starts with two dashes (--)")
else -> resultValid(hint = R.string.ext__validation__hint_property)
}
}
}
@ -163,9 +162,9 @@ object ExtensionValidation {
validator { input ->
val str = input.trim()
when {
str.isBlank() -> resultInvalid(error = "Please enter a color string")
str.isBlank() -> resultInvalid(error = R.string.ext__validation__enter_color)
dev.patrickgold.florisboard.lib.snygg.value.SnyggSolidColorValue.deserialize(str).isFailure -> {
resultInvalid(error = "Please enter a valid color string")
resultInvalid(error = R.string.ext__validation__error_color)
}
else -> resultValid()
}
@ -178,9 +177,9 @@ object ExtensionValidation {
validator { str ->
val floatValue = str.toFloatOrNull()
when {
str.isBlank() -> resultInvalid(error = "Please enter a dp size")
floatValue == null -> resultInvalid(error = "Please enter a valid number")
floatValue < 0f -> resultInvalid(error = "Please enter a positive number (>=0)")
str.isBlank() -> resultInvalid(error = R.string.ext__validation__enter_dp_size)
floatValue == null -> resultInvalid(error = R.string.ext__validation__enter_valid_number)
floatValue < 0f -> resultInvalid(error = R.string.ext__validation__enter_positive_number)
else -> resultValid()
}
}
@ -192,10 +191,10 @@ object ExtensionValidation {
validator { str ->
val intValue = str.toIntOrNull()
when {
str.isBlank() -> resultInvalid(error = "Please enter a percent size")
intValue == null -> resultInvalid(error = "Please enter a valid number")
intValue < 0 || intValue > 100 -> resultInvalid(error = "Please enter a positive number between 0 and 100")
intValue > 50 -> resultValid(hint = "Any value above 50% will behave as if you set 50%, consider lowering your percent size")
str.isBlank() -> resultInvalid(error = R.string.ext__validation__enter_percent_size)
intValue == null -> resultInvalid(error = R.string.ext__validation__enter_valid_number)
intValue < 0 || intValue > 100 -> resultInvalid(error = R.string.ext__validation__enter_number_between_0_100)
intValue > 50 -> resultValid(hint = R.string.ext__validation__hint_value_above_50_percent)
else -> resultValid()
}
}

View File

@ -17,7 +17,6 @@
package dev.patrickgold.florisboard.lib.io
import android.content.Context
import dev.patrickgold.florisboard.appContext
import dev.patrickgold.florisboard.ime.keyboard.AbstractKeyData
import dev.patrickgold.florisboard.ime.keyboard.CaseSelector
import dev.patrickgold.florisboard.ime.keyboard.CharWidthSelector
@ -66,122 +65,118 @@ val DefaultJsonConfig = Json {
}
}
// TODO: fully deprecate and remove this class, should be substituted by extension funs on the actual file and stream
// instances
class AssetManager(context: Context) {
val appContext by context.appContext()
fun delete(ref: FlorisRef) {
when {
ref.isCache || ref.isInternal -> {
ref.absoluteFile(appContext).delete()
}
else -> error("Can not delete directory/file in location '${ref.scheme}'.")
fun FlorisRef.delete(context: Context) {
when {
isCache || isInternal -> {
absoluteFile(context).delete()
}
}
fun hasAsset(ref: FlorisRef): Boolean {
return when {
ref.isAssets -> {
try {
val file = File(ref.relativePath)
val list = appContext.assets.list(file.parent?.toString() ?: "")
list?.contains(file.name) == true
} catch (e: Exception) {
false
}
}
ref.isCache || ref.isInternal -> {
val file = File(ref.absolutePath(appContext))
file.exists() && file.isFile
}
else -> false
}
}
fun list(ref: FlorisRef) = list(ref, files = true, dirs = true)
fun listFiles(ref: FlorisRef) = list(ref, files = true, dirs = false)
fun listDirs(ref: FlorisRef) = list(ref, files = false, dirs = true)
private fun list(ref: FlorisRef, files: Boolean, dirs: Boolean) = runCatching<List<FlorisRef>> {
when {
!files && !dirs -> listOf()
ref.isAssets -> {
appContext.assets.list(ref.relativePath)?.mapNotNull { fileName ->
val subList = appContext.assets.list("${ref.relativePath}/$fileName") ?: return@mapNotNull null
when {
files && dirs || files && subList.isEmpty() || dirs && subList.isNotEmpty() -> {
ref.subRef(fileName)
}
else -> null
}
} ?: listOf()
}
ref.isCache || ref.isInternal -> {
val dir = ref.absoluteFile(appContext)
if (dir.isDirectory) {
when {
files && dirs -> dir.listFiles()?.toList()
files -> dir.listFiles()?.filter { it.isFile }
dirs -> dir.listFiles()?.filter { it.isDirectory }
else -> null
}!!.map { ref.subRef(it.name) }
} else {
listOf()
}
}
else -> error("Unsupported FlorisRef source!")
}
}
fun <T> loadJsonAsset(
ref: FlorisRef,
serializer: KSerializer<T>,
jsonConfig: Json = DefaultJsonConfig,
) = runCatching<T> {
val jsonStr = loadTextAsset(ref).getOrThrow()
jsonConfig.decodeFromString(serializer, jsonStr)
}
inline fun <reified T> loadJsonAsset(jsonStr: String, jsonConfig: Json = DefaultJsonConfig): Result<T> {
return runCatching { jsonConfig.decodeFromString(jsonStr) }
}
fun <T> loadJsonAsset(
jsonStr: String,
serializer: KSerializer<T>,
jsonConfig: Json = DefaultJsonConfig,
) = runCatching<T> {
jsonConfig.decodeFromString(serializer, jsonStr)
}
fun loadTextAsset(ref: FlorisRef): Result<String> {
return when {
ref.isAssets -> runCatching {
appContext.assets.reader(ref.relativePath).use { it.readText() }
}
ref.isCache || ref.isInternal -> {
val file = File(ref.absolutePath(appContext))
val contents = readTextFile(file).getOrElse { return resultErr(it) }
if (contents.isBlank()) {
resultErrStr("File is blank!")
} else {
resultOk(contents)
}
}
else -> resultErrStr("Unsupported asset ref!")
}
}
/**
* Reads a given [file] and returns its content.
*
* @param file The file object.
* @return The contents of the file or an empty string, if the file does not exist.
*/
private fun readTextFile(file: File) = runCatching {
file.readText(Charsets.UTF_8)
else -> error("Can not delete directory/file in location '${scheme}'.")
}
}
fun FlorisRef.hasAsset(context: Context): Boolean {
return when {
isAssets -> {
try {
val file = File(relativePath)
val list = context.assets.list(file.parent?.toString() ?: "")
list?.contains(file.name) == true
} catch (e: Exception) {
false
}
}
isCache || isInternal -> {
val file = File(absolutePath(context))
file.exists() && file.isFile
}
else -> false
}
}
fun FlorisRef.list(context: Context) = list(context, files = true, dirs = true)
fun FlorisRef.listFiles(context: Context) = list(context, files = true, dirs = false)
fun FlorisRef.listDirs(context: Context) = list(context, files = false, dirs = true)
private fun FlorisRef.list(appContext: Context, files: Boolean, dirs: Boolean) = runCatching<List<FlorisRef>> {
when {
!files && !dirs -> listOf()
isAssets -> {
appContext.assets.list(relativePath)?.mapNotNull { fileName ->
val subList = appContext.assets.list("${relativePath}/$fileName") ?: return@mapNotNull null
when {
files && dirs || files && subList.isEmpty() || dirs && subList.isNotEmpty() -> {
subRef(fileName)
}
else -> null
}
} ?: listOf()
}
isCache || isInternal -> {
val dir = absoluteFile(appContext)
if (dir.isDirectory) {
when {
files && dirs -> dir.listFiles()?.toList()
files -> dir.listFiles()?.filter { it.isFile }
dirs -> dir.listFiles()?.filter { it.isDirectory }
else -> null
}!!.map { subRef(it.name) }
} else {
listOf()
}
}
else -> error("Unsupported FlorisRef source!")
}
}
fun <T> FlorisRef.loadJsonAsset(
context: Context,
serializer: KSerializer<T>,
jsonConfig: Json = DefaultJsonConfig,
) = runCatching<T> {
val jsonStr = loadTextAsset(context).getOrThrow()
jsonConfig.decodeFromString(serializer, jsonStr)
}
inline fun <reified T> loadJsonAsset(jsonStr: String, jsonConfig: Json = DefaultJsonConfig): Result<T> {
return runCatching { jsonConfig.decodeFromString(jsonStr) }
}
fun <T> loadJsonAsset(
jsonStr: String,
serializer: KSerializer<T>,
jsonConfig: Json = DefaultJsonConfig,
) = runCatching<T> {
jsonConfig.decodeFromString(serializer, jsonStr)
}
fun FlorisRef.loadTextAsset(context: Context): Result<String> {
return when {
isAssets -> runCatching {
context.assets.reader(relativePath).use { it.readText() }
}
isCache || isInternal -> {
val file = File(absolutePath(context))
val contents = readTextFile(file).getOrElse { return resultErr(it) }
if (contents.isBlank()) {
resultErrStr("File is blank!")
} else {
resultOk(contents)
}
}
else -> resultErrStr("Unsupported asset ref!")
}
}
/**
* Reads a given [file] and returns its content.
*
* @param file The file object.
* @return The contents of the file or an empty string, if the file does not exist.
*/
private fun readTextFile(file: File) = runCatching {
file.readText(Charsets.UTF_8)
}

View File

@ -18,7 +18,6 @@ package dev.patrickgold.florisboard.lib.io
import android.content.Context
import android.net.Uri
import dev.patrickgold.florisboard.assetManager
import dev.patrickgold.florisboard.lib.android.copyRecursively
import dev.patrickgold.florisboard.lib.android.write
import java.io.FileOutputStream
@ -28,10 +27,9 @@ import java.util.zip.ZipOutputStream
object ZipUtils {
fun readFileFromArchive(context: Context, zipRef: FlorisRef, relPath: String) = runCatching<String> {
val assetManager by context.assetManager()
when {
zipRef.isAssets -> {
assetManager.loadTextAsset(zipRef.subRef(relPath)).getOrThrow()
zipRef.subRef(relPath).loadTextAsset(context).getOrThrow()
}
zipRef.isCache || zipRef.isInternal -> {
val flexHandle = FsFile(zipRef.absolutePath(context))
@ -141,7 +139,18 @@ object ZipUtils {
val flexEntries = flexFile.entries()
while (flexEntries.hasMoreElements()) {
val flexEntry = flexEntries.nextElement()
if (flexEntry.name.length > 255) {
continue
}
val flexEntryFile = FsFile(dstDir, flexEntry.name)
val canonicalDestinationDirPath = dstDir.canonicalPath
val canonicalDestinationFilePath = flexEntryFile.canonicalPath
if (canonicalDestinationFilePath.length > 1023) {
continue
}
if (!canonicalDestinationFilePath.startsWith(canonicalDestinationDirPath + FsFile.separator)) {
continue
}
if (flexEntry.isDirectory) {
flexEntryFile.mkdir()
} else {
@ -153,6 +162,9 @@ object ZipUtils {
private fun ZipFile.copy(srcEntry: ZipEntry, dstFile: FsFile) {
dstFile.outputStream().use { outStream ->
if (srcEntry.size > 100000000) {
return
}
this.getInputStream(srcEntry).use { inStream ->
inStream.copyTo(outStream)
}

View File

@ -139,16 +139,13 @@
<string name="pref__theme__sunset_time__label" comment="Label of the sunset time preference">Sunset time</string>
<string name="pref__theme__day" comment="Label of the day group (day means light theme)">Day theme</string>
<string name="pref__theme__night" comment="Label of the night group (night means dark theme)">Night theme</string>
<string name="pref__theme__any_theme__label" comment="Label of the theme selector preference">Selected theme</string>
<string name="pref__theme__any_theme_adapt_to_app__label" comment="Label of the theme adapt to app preference">Adapt colors to app</string>
<string name="pref__theme__any_theme_adapt_to_app__summary" comment="Summary of the theme adapt to app preference">Theme colors adapt to those in the current app, if the target app supports this.</string>
<string name="settings__theme_manager__title_manage" comment="Title of the theme manager screen for managing installed and custom themes">Manage installed themes</string>
<string name="pref__theme__source_assets" comment="Label for the theme source field">FlorisBoard App Assets</string>
<string name="pref__theme__source_internal" comment="Label for the theme source field">Internal Storage</string>
<string name="pref__theme__source_external" comment="Label for the theme source field">External Provider</string>
<string name="settings__theme_manager__title_day" comment="Title of the theme manager screen for day theme selection">Select day theme</string>
<string name="settings__theme_manager__title_night" comment="Title of the theme manager screen for night theme selection">Select night theme</string>
<string name="settings__theme_manager__title_manage" comment="Title of the theme manager screen for managing installed and custom themes">Manage installed themes</string>
<string name="settings__theme_editor__fine_tune__title">Fine tune editor</string>
<string name="settings__theme_editor__fine_tune__level">Editing level</string>
@ -620,6 +617,10 @@
<!-- Extension strings -->
<string name="ext__home__title">Addons &amp; Extensions</string>
<string name="ext__list__ext_theme">Theme extensions</string>
<string name="ext__list__ext_keyboard">Keyboard extensions</string>
<string name="ext__list__ext_languagepack">Language pack extensions</string>
<string name="ext__meta__authors">Authors</string>
<string name="ext__meta__components">Bundled components</string>
<string name="ext__meta__components_theme">Bundled themes</string>
@ -672,7 +673,39 @@
<string name="ext__import__file_skip_ext_not_supported" comment="Reason string when file is loaded in incorrect context">Expected a media file (image, audio, font, etc.) but found an extension archive.</string>
<string name="ext__import__file_skip_media_not_supported" comment="Reason string when file is loaded in incorrect context">Expected an extension archive but found a media file (image, audio, font, etc.).</string>
<string name="ext__import__error_unexpected_exception" comment="Label when an error occurred during import. The error message will be appended below this text view">An unexpected error occurred during import. The following details were provided:</string>
<string name="ext__validation__enter_package_name">Please enter a package name</string>
<string name="ext__validation__error_package_name">Package name does not match regex {id_regex}</string>
<string name="ext__validation__enter_version">Please enter a version</string>
<string name="ext__validation__enter_title">Please enter a title</string>
<string name="ext__validation__enter_maintainer">Please enter at least one valid maintainer</string>
<string name="ext__validation__enter_license">Please enter a license identifier</string>
<string name="ext__validation__enter_component_id">Please enter a component ID</string>
<string name="ext__validation__error_component_id">Please enter a component ID matching {component_id_regex}</string>
<string name="ext__validation__enter_component_label">Please enter a component label</string>
<string name="ext__validation__hint_component_label_to_long">Your component label is quite long, which may lead to clipping in the UI</string>
<string name="ext__validation__error_author">Please enter at least one valid author</string>
<string name="ext__validation__error_stylesheet_path_blank">The stylesheet path must not be blank</string>
<string name="ext__validation__error_stylesheet_path">Please enter a valid stylesheet path matching {stylesheet_path_regex}</string>
<string name="ext__validation__enter_property">Please enter a variable name</string>
<string name="ext__validation__error_property">Please enter a valid variable name matching {variable_name_regex}</string>
<string name="ext__validation__hint_property" tools:ignore="TypographyDashes">By convention a FlorisCSS variable name starts with two dashes (--)</string>
<string name="ext__validation__enter_color">Please enter a color string</string>
<string name="ext__validation__error_color">Please enter a valid color string</string>
<string name="ext__validation__enter_dp_size">Please enter a dp size</string>
<string name="ext__validation__enter_valid_number">Please enter a valid number</string>
<string name="ext__validation__enter_positive_number">Please enter a positive number (>=0)</string>
<string name="ext__validation__enter_percent_size">Please enter a percent size</string>
<string name="ext__validation__enter_number_between_0_100">Please enter a positive number between 0 and 100</string>
<string name="ext__validation__hint_value_above_50_percent">Any value above 50% will behave as if you set 50%, consider lowering your percent size</string>
<string name="ext__update_box__internet_permission_hint">Since this app does not have Internet permission, updates for installed extensions must be checked manually.</string>
<string name="ext__update_box__search_for_updates">Search for Updates</string>
<string name="ext__addon_management_box__managing_placeholder">Managing {extensions}</string>
<string name="ext__addon_management_box__addon_manager_info">All tasks related to importing, exporting, creating, customizing, and removing extensions can be handled through the centralized addon manager.</string>
<string name="ext__addon_management_box__go_to_page">Go to {ext_home_title}</string>
<string name="ext__home__info">You can download and install extensions from the FlorisBoard Addons Store or import any extension file you have downloaded from the internet.</string>
<string name="ext__home__visit_store">Visit Addons Store</string>
<string name="ext__home__manage_extensions">Manage installed extensions</string>
<string name="ext__list__view_details">View details</string>
<!-- Action strings -->
<string name="action__add">Add</string>