Theme & Extension System
Overview
FlorisBoard features a powerful extension system that allows users to customize themes, keyboard layouts, and language support through packaged .flex
files.
Introduction
The extension system is a core feature that makes FlorisBoard highly customizable. Extensions are packaged as .flex
archive files containing:
- Themes: Visual styling with Snygg CSS-like syntax
- Keyboard Layouts: Custom keyboard arrangements
- Language Packs: Dictionaries and NLP data for specific languages
- Composers: Input method composers for complex scripts
- Popup Mappings: Long-press character variants
Key Concepts
Extension Types
FlorisBoard supports three main extension types:
Keyboard Extensions
@Serializable
data class KeyboardExtension(
override val meta: ExtensionMeta,
override val dependencies: List<String>? = null,
val composers: List<Composer> = listOf(),
val currencySets: List<CurrencySet> = listOf(),
val layouts: Map<String, List<LayoutArrangementComponent>> = mapOf(),
val punctuationRules: List<PunctuationRule> = listOf(),
val popupMappings: List<PopupMappingComponent> = listOf(),
val subtypePresets: List<SubtypePreset> = listOf(),
) : Extension()
Theme Extensions
@Serializable
class ThemeExtension(
override val meta: ExtensionMeta,
override val dependencies: List<String>? = null,
val themes: List<ThemeExtensionComponentImpl>,
) : Extension()
Language Pack Extensions
@Serializable
class LanguagePackExtension(
override val meta: ExtensionMeta,
override val dependencies: List<String>? = null,
val items: List<LanguagePackComponent> = listOf(),
val hanShapeBasedSQLite: String = "han.sqlite3",
) : Extension()
Extension Metadata
Every extension has metadata defined in extension.json
:
@Serializable
data class ExtensionMeta(
val id: String, // Unique identifier (e.g., "org.example.mytheme")
val version: String, // Semantic version (e.g., "1.0.0")
val title: String, // Display name
val description: String, // Description
val keywords: List<String>? = null,
val homepage: String? = null,
val issueTracker: String? = null,
val maintainers: List<String>, // List of maintainers
val license: String, // License identifier (e.g., "apache-2.0")
)
Extension Manager
The ExtensionManager
handles loading, indexing, and managing extensions:
class ExtensionManager(context: Context) {
val keyboardExtensions = ExtensionIndex(KeyboardExtension.serializer(), IME_KEYBOARD_PATH)
val themes = ExtensionIndex(ThemeExtension.serializer(), IME_THEME_PATH)
val languagePacks = ExtensionIndex(LanguagePackExtension.serializer(), IME_LANGUAGEPACK_PATH)
fun init()
fun import(ext: Extension)
fun export(ext: Extension, uri: Uri)
fun getExtensionById(id: String): Extension?
fun canDelete(ext: Extension): Boolean
fun delete(ext: Extension)
}
Extension Lifecycle
Extensions follow this lifecycle:
Install → Index → Load → Use → Unload → Delete
abstract class Extension {
var workingDir: FsDir? = null
var sourceRef: FlorisRef? = null
abstract val meta: ExtensionMeta
abstract val dependencies: List<String>?
fun isLoaded() = workingDir != null
open fun onBeforeLoad(context: Context, cacheDir: FsDir)
open fun onAfterLoad(context: Context, cacheDir: FsDir)
fun load(context: Context, force: Boolean = false): Result<Unit>
fun unload(context: Context)
}
Implementation Details
Extension File Structure
A .flex
file is a ZIP archive with this structure:
mytheme.flex
├── extension.json # Metadata
├── README.md # Optional documentation
├── LICENSE # License file
└── ime/
└── theme/
└── mytheme/
├── day.json # Day theme stylesheet
└── night.json # Night theme stylesheet
Extension Indexing
Extensions are indexed from two locations:
- Assets: Built-in extensions in
app/src/main/assets/ime/
- Internal Storage: User-installed extensions in app's internal storage
inner class ExtensionIndex<T : Extension>(
private val serializer: KSerializer<T>,
modulePath: String,
) : LiveData<List<T>>() {
fun init() {
ioScope.launch {
initGuard.withLock {
internalModuleDir = internalModuleRef.absoluteFile(appContext)
internalModuleDir.mkdirs()
refreshGuard.withLock {
staticExtensions = indexAssetsModule()
refresh()
}
// Watch for file changes
fileObserver = FileObserver(internalModuleDir, FILE_OBSERVER_MASK) { event, path ->
ioScope.launch {
refreshGuard.withLock { refresh() }
}
}.also { it.startWatching() }
}
}
}
}
Theme System (Snygg)
FlorisBoard uses a custom styling engine called Snygg (Swedish for "stylish"):
Theme Stylesheet Example
{
"$": "ime.extension.theme",
"meta": {
"id": "org.example.mytheme",
"version": "1.0.0",
"title": "My Custom Theme",
"maintainers": ["Your Name <email@example.com>"],
"license": "apache-2.0"
},
"themes": [
{
"id": "day",
"label": "Day",
"authors": ["Your Name"],
"isNightTheme": false,
"stylesheetPath": "ime/theme/mytheme/day.json"
}
]
}
Stylesheet Definition
{
"defines": {
"--primary": "#4CAF50",
"--background": "#FFFFFF",
"--surface": "#F5F5F5",
"--on-primary": "#FFFFFF",
"--shape": "8dp"
},
"rules": {
"keyboard": {
"background": "var(--background)",
"foreground": "var(--on-background)"
},
"key": {
"background": "var(--surface)",
"foreground": "var(--on-surface)",
"shape": "var(--shape)",
"shadow-elevation": "2dp"
},
"key:pressed": {
"background": "var(--primary)",
"foreground": "var(--on-primary)"
}
}
}
Extension Loading
fun load(context: Context, force: Boolean = false): Result<Unit> {
val cacheDir = FsDir(context.cacheDir, meta.id)
if (cacheDir.exists()) {
if (force) {
cacheDir.deleteRecursively()
}
}
cacheDir.mkdirs()
val sourceRef = sourceRef ?: return resultOk()
onBeforeLoad(context, cacheDir)
ZipUtils.unzip(context, sourceRef, cacheDir).onFailure { return resultErr(it) }
workingDir = cacheDir
onAfterLoad(context, cacheDir)
return resultOk()
}
Code Examples
Creating a Theme Extension
{
"$": "ime.extension.theme",
"meta": {
"id": "com.example.ocean",
"version": "1.0.0",
"title": "Ocean Theme",
"description": "A calming blue theme inspired by the ocean",
"maintainers": ["Ocean Dev <dev@example.com>"],
"license": "apache-2.0"
},
"themes": [
{
"id": "ocean_day",
"label": "Ocean Day",
"authors": ["Ocean Dev"],
"isNightTheme": false,
"stylesheetPath": "ime/theme/ocean/day.json"
},
{
"id": "ocean_night",
"label": "Ocean Night",
"authors": ["Ocean Dev"],
"isNightTheme": true,
"stylesheetPath": "ime/theme/ocean/night.json"
}
]
}
Creating a Keyboard Layout Extension
{
"$": "ime.extension.keyboard",
"meta": {
"id": "com.example.customlayout",
"version": "1.0.0",
"title": "Custom Layouts",
"maintainers": ["Layout Dev <dev@example.com>"],
"license": "apache-2.0"
},
"layouts": {
"characters": [
{
"id": "custom_qwerty",
"label": "Custom QWERTY",
"authors": ["Layout Dev"],
"direction": "ltr",
"arrangementFile": "layouts/characters/custom_qwerty.json"
}
]
}
}
Importing an Extension
fun importExtension(uri: Uri) {
val cacheManager by context.cacheManager()
val extensionManager by context.extensionManager()
// Read extension from URI
val workspace = cacheManager.importer.new()
cacheManager.readFromUriIntoCache(uri)
// Parse and validate
val ext = parseExtension(workspace)
// Import into extension manager
extensionManager.import(ext)
// Cleanup
workspace.close()
}
Exporting an Extension
fun exportExtension(ext: Extension, uri: Uri) {
val extensionManager by context.extensionManager()
extensionManager.export(ext, uri)
}
Best Practices
1. Use Semantic Versioning
{
"version": "1.2.3" // MAJOR.MINOR.PATCH
}
2. Provide Comprehensive Metadata
{
"meta": {
"id": "org.example.mytheme",
"title": "My Theme",
"description": "A detailed description of what makes this theme special",
"keywords": ["dark", "minimal", "modern"],
"homepage": "https://example.org/mytheme",
"issueTracker": "https://github.com/example/mytheme/issues",
"maintainers": ["Name <email@example.com>"],
"license": "apache-2.0"
}
}
3. Include Documentation
Always include README.md and LICENSE files in your extension.
4. Test Thoroughly
Test your extension with:
- Different screen sizes
- Light and dark modes
- Various Android versions
- Different languages (for layouts)
5. Follow Naming Conventions
Extension ID: org.example.extensionname (reverse domain)
File name: org.example.extensionname.flex
Common Patterns
Multi-Theme Extension
{
"themes": [
{
"id": "light",
"label": "Light",
"isNightTheme": false,
"stylesheetPath": "ime/theme/mytheme/light.json"
},
{
"id": "dark",
"label": "Dark",
"isNightTheme": true,
"stylesheetPath": "ime/theme/mytheme/dark.json"
},
{
"id": "amoled",
"label": "AMOLED",
"isNightTheme": true,
"stylesheetPath": "ime/theme/mytheme/amoled.json"
}
]
}
Extension Dependencies
{
"meta": {
"id": "org.example.advanced",
"version": "1.0.0"
},
"dependencies": [
"org.florisboard.layouts",
"org.example.baselayouts"
]
}
Troubleshooting
Extension Not Loading
Solutions:
- Verify JSON syntax is valid
- Check extension.json has correct structure
- Ensure file paths are correct
- Verify extension ID is unique
- Check logs for parsing errors
Theme Not Applying
Solutions:
- Verify stylesheet path is correct
- Check CSS syntax in stylesheet
- Ensure all required properties are defined
- Test with default theme first
- Validate color values
Layout Not Appearing
Solutions:
- Check layout JSON is valid
- Verify arrangement file path
- Ensure layout is registered in extension.json
- Check layout type matches usage
- Validate key codes and labels
Related Topics
- Layout System - Creating custom layouts
- Internationalization - Language support
- Custom UI Components - Snygg theme system
- Project Structure - Extension directories
Next Steps
- Explore existing extensions
- Check the FlorisBoard documentation for upcoming guides on themes, layouts, and distribution
Note: This documentation is continuously being improved. Contributions are welcome!