From af1573d3f72f5773227b1aaa535b3878ccb00dab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mikkel=20Bj=C3=B8rnmose=20Bundgaard?= Date: Wed, 10 Dec 2025 15:00:08 +0100 Subject: [PATCH 01/34] Themes working --- .../assets/Themes/BigPictureRow/manifest.xml | 7 + .../BigPictureRow/sections/carousel.xml | 12 + .../Themes/BigPictureRow/sections/grid.xml | 11 + .../sections/templates-common.xml | 57 +++ .../assets/Themes/BigPictureRow/theme.xml | 37 ++ .../assets/Themes/BigPictureRow/variables.xml | 3 + .../assets/Themes/CapsuleGrid/manifest.xml | 7 + .../main/assets/Themes/CapsuleGrid/theme.xml | 46 ++ .../assets/Themes/DefaultList/manifest.xml | 7 + .../main/assets/Themes/DefaultList/theme.xml | 34 ++ .../assets/Themes/DefaultList/variables.xml | 4 + .../assets/Themes/HeroCarousel/manifest.xml | 7 + .../main/assets/Themes/HeroCarousel/theme.xml | 37 ++ app/src/main/java/app/gamenative/PluviaApp.kt | 3 + .../main/java/app/gamenative/PrefManager.kt | 18 + .../java/app/gamenative/theme/ThemeManager.kt | 419 +++++++++++++++++ .../gamenative/theme/io/IncludeResolver.kt | 208 +++++++++ .../app/gamenative/theme/io/ThemeLoader.kt | 223 +++++++++ .../app/gamenative/theme/io/ThemeXmlMapper.kt | 389 ++++++++++++++++ .../gamenative/theme/media/AssetResolver.kt | 184 ++++++++ .../app/gamenative/theme/media/MediaPolicy.kt | 65 +++ .../theme/media/MediaSourceManager.kt | 117 +++++ .../gamenative/theme/media/VideoController.kt | 172 +++++++ .../app/gamenative/theme/model/Binding.kt | 57 +++ .../java/app/gamenative/theme/model/Card.kt | 19 + .../java/app/gamenative/theme/model/Engine.kt | 12 + .../java/app/gamenative/theme/model/Enums.kt | 68 +++ .../java/app/gamenative/theme/model/Fixed.kt | 100 ++++ .../java/app/gamenative/theme/model/Layers.kt | 146 ++++++ .../java/app/gamenative/theme/model/Layout.kt | 92 ++++ .../app/gamenative/theme/model/Manifest.kt | 17 + .../java/app/gamenative/theme/model/Media.kt | 37 ++ .../java/app/gamenative/theme/model/Source.kt | 75 +++ .../gamenative/theme/model/StateTransition.kt | 121 +++++ .../java/app/gamenative/theme/model/Types.kt | 33 ++ .../app/gamenative/theme/perf/PerfConfig.kt | 123 +++++ .../app/gamenative/theme/perf/PerfOverlay.kt | 181 ++++++++ .../theme/runtime/FixedElementRenderer.kt | 375 +++++++++++++++ .../gamenative/theme/runtime/FocusEngine.kt | 229 ++++++++++ .../gamenative/theme/runtime/LayoutEngine.kt | 285 ++++++++++++ .../gamenative/theme/runtime/StateEngine.kt | 362 +++++++++++++++ .../theme/runtime/ThemedGameGrid.kt | 430 ++++++++++++++++++ .../theme/runtime/layers/LayerRenderers.kt | 284 ++++++++++++ .../theme/validate/ThemeValidator.kt | 401 ++++++++++++++++ .../ui/screen/library/LibraryScreen.kt | 226 ++++++++- .../library/components/LibraryBottomSheet.kt | 57 +-- .../library/components/LibraryListPane.kt | 17 +- .../screen/settings/SettingsGroupInterface.kt | 27 ++ .../ui/screen/settings/ThemePickerScreen.kt | 95 ++++ app/src/main/res/values-da/strings.xml | 1 + app/src/main/res/values/strings.xml | 13 + .../theme/ThemeLoaderIncludeTest.kt | 104 +++++ .../gamenative/theme/ThemeLoaderSmokeTest.kt | 94 ++++ .../gamenative/theme/ThemeValidatorTest.kt | 106 +++++ .../theme/media/MediaResolverTest.kt | 129 ++++++ .../theme/media/VideoControllerTest.kt | 85 ++++ .../theme/runtime/FocusEngineTest.kt | 42 ++ 57 files changed, 6461 insertions(+), 49 deletions(-) create mode 100644 app/src/main/assets/Themes/BigPictureRow/manifest.xml create mode 100644 app/src/main/assets/Themes/BigPictureRow/sections/carousel.xml create mode 100644 app/src/main/assets/Themes/BigPictureRow/sections/grid.xml create mode 100644 app/src/main/assets/Themes/BigPictureRow/sections/templates-common.xml create mode 100644 app/src/main/assets/Themes/BigPictureRow/theme.xml create mode 100644 app/src/main/assets/Themes/BigPictureRow/variables.xml create mode 100644 app/src/main/assets/Themes/CapsuleGrid/manifest.xml create mode 100644 app/src/main/assets/Themes/CapsuleGrid/theme.xml create mode 100644 app/src/main/assets/Themes/DefaultList/manifest.xml create mode 100644 app/src/main/assets/Themes/DefaultList/theme.xml create mode 100644 app/src/main/assets/Themes/DefaultList/variables.xml create mode 100644 app/src/main/assets/Themes/HeroCarousel/manifest.xml create mode 100644 app/src/main/assets/Themes/HeroCarousel/theme.xml create mode 100644 app/src/main/java/app/gamenative/theme/ThemeManager.kt create mode 100644 app/src/main/java/app/gamenative/theme/io/IncludeResolver.kt create mode 100644 app/src/main/java/app/gamenative/theme/io/ThemeLoader.kt create mode 100644 app/src/main/java/app/gamenative/theme/io/ThemeXmlMapper.kt create mode 100644 app/src/main/java/app/gamenative/theme/media/AssetResolver.kt create mode 100644 app/src/main/java/app/gamenative/theme/media/MediaPolicy.kt create mode 100644 app/src/main/java/app/gamenative/theme/media/MediaSourceManager.kt create mode 100644 app/src/main/java/app/gamenative/theme/media/VideoController.kt create mode 100644 app/src/main/java/app/gamenative/theme/model/Binding.kt create mode 100644 app/src/main/java/app/gamenative/theme/model/Card.kt create mode 100644 app/src/main/java/app/gamenative/theme/model/Engine.kt create mode 100644 app/src/main/java/app/gamenative/theme/model/Enums.kt create mode 100644 app/src/main/java/app/gamenative/theme/model/Fixed.kt create mode 100644 app/src/main/java/app/gamenative/theme/model/Layers.kt create mode 100644 app/src/main/java/app/gamenative/theme/model/Layout.kt create mode 100644 app/src/main/java/app/gamenative/theme/model/Manifest.kt create mode 100644 app/src/main/java/app/gamenative/theme/model/Media.kt create mode 100644 app/src/main/java/app/gamenative/theme/model/Source.kt create mode 100644 app/src/main/java/app/gamenative/theme/model/StateTransition.kt create mode 100644 app/src/main/java/app/gamenative/theme/model/Types.kt create mode 100644 app/src/main/java/app/gamenative/theme/perf/PerfConfig.kt create mode 100644 app/src/main/java/app/gamenative/theme/perf/PerfOverlay.kt create mode 100644 app/src/main/java/app/gamenative/theme/runtime/FixedElementRenderer.kt create mode 100644 app/src/main/java/app/gamenative/theme/runtime/FocusEngine.kt create mode 100644 app/src/main/java/app/gamenative/theme/runtime/LayoutEngine.kt create mode 100644 app/src/main/java/app/gamenative/theme/runtime/StateEngine.kt create mode 100644 app/src/main/java/app/gamenative/theme/runtime/ThemedGameGrid.kt create mode 100644 app/src/main/java/app/gamenative/theme/runtime/layers/LayerRenderers.kt create mode 100644 app/src/main/java/app/gamenative/theme/validate/ThemeValidator.kt create mode 100644 app/src/main/java/app/gamenative/ui/screen/settings/ThemePickerScreen.kt create mode 100644 app/src/test/java/app/gamenative/theme/ThemeLoaderIncludeTest.kt create mode 100644 app/src/test/java/app/gamenative/theme/ThemeLoaderSmokeTest.kt create mode 100644 app/src/test/java/app/gamenative/theme/ThemeValidatorTest.kt create mode 100644 app/src/test/java/app/gamenative/theme/media/MediaResolverTest.kt create mode 100644 app/src/test/java/app/gamenative/theme/media/VideoControllerTest.kt create mode 100644 app/src/test/java/app/gamenative/theme/runtime/FocusEngineTest.kt diff --git a/app/src/main/assets/Themes/BigPictureRow/manifest.xml b/app/src/main/assets/Themes/BigPictureRow/manifest.xml new file mode 100644 index 000000000..68f9162a2 --- /dev/null +++ b/app/src/main/assets/Themes/BigPictureRow/manifest.xml @@ -0,0 +1,7 @@ + + BigPictureRow + 1.0.0 + 1 + 0.0.0 + + diff --git a/app/src/main/assets/Themes/BigPictureRow/sections/carousel.xml b/app/src/main/assets/Themes/BigPictureRow/sections/carousel.xml new file mode 100644 index 000000000..a4efbd0ad --- /dev/null +++ b/app/src/main/assets/Themes/BigPictureRow/sections/carousel.xml @@ -0,0 +1,12 @@ + + + + diff --git a/app/src/main/assets/Themes/BigPictureRow/sections/grid.xml b/app/src/main/assets/Themes/BigPictureRow/sections/grid.xml new file mode 100644 index 000000000..eb48ab99a --- /dev/null +++ b/app/src/main/assets/Themes/BigPictureRow/sections/grid.xml @@ -0,0 +1,11 @@ + + + + diff --git a/app/src/main/assets/Themes/BigPictureRow/sections/templates-common.xml b/app/src/main/assets/Themes/BigPictureRow/sections/templates-common.xml new file mode 100644 index 000000000..3fb09c233 --- /dev/null +++ b/app/src/main/assets/Themes/BigPictureRow/sections/templates-common.xml @@ -0,0 +1,57 @@ + + + + + + + diff --git a/app/src/main/assets/Themes/BigPictureRow/theme.xml b/app/src/main/assets/Themes/BigPictureRow/theme.xml new file mode 100644 index 000000000..bdf45ff38 --- /dev/null +++ b/app/src/main/assets/Themes/BigPictureRow/theme.xml @@ -0,0 +1,37 @@ + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/assets/Themes/BigPictureRow/variables.xml b/app/src/main/assets/Themes/BigPictureRow/variables.xml new file mode 100644 index 000000000..e58d99d00 --- /dev/null +++ b/app/src/main/assets/Themes/BigPictureRow/variables.xml @@ -0,0 +1,3 @@ + + + diff --git a/app/src/main/assets/Themes/CapsuleGrid/manifest.xml b/app/src/main/assets/Themes/CapsuleGrid/manifest.xml new file mode 100644 index 000000000..ca8914749 --- /dev/null +++ b/app/src/main/assets/Themes/CapsuleGrid/manifest.xml @@ -0,0 +1,7 @@ + + CapsuleGrid + 1.0.0 + 1 + 0.0.0 + + diff --git a/app/src/main/assets/Themes/CapsuleGrid/theme.xml b/app/src/main/assets/Themes/CapsuleGrid/theme.xml new file mode 100644 index 000000000..b91c9d75a --- /dev/null +++ b/app/src/main/assets/Themes/CapsuleGrid/theme.xml @@ -0,0 +1,46 @@ + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/assets/Themes/DefaultList/manifest.xml b/app/src/main/assets/Themes/DefaultList/manifest.xml new file mode 100644 index 000000000..ea080bad6 --- /dev/null +++ b/app/src/main/assets/Themes/DefaultList/manifest.xml @@ -0,0 +1,7 @@ + + DefaultList + 1.0.0 + 1 + 0.0.0 + + diff --git a/app/src/main/assets/Themes/DefaultList/theme.xml b/app/src/main/assets/Themes/DefaultList/theme.xml new file mode 100644 index 000000000..39c59489f --- /dev/null +++ b/app/src/main/assets/Themes/DefaultList/theme.xml @@ -0,0 +1,34 @@ + + + +
+ + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/assets/Themes/DefaultList/variables.xml b/app/src/main/assets/Themes/DefaultList/variables.xml new file mode 100644 index 000000000..2eb9ad8d5 --- /dev/null +++ b/app/src/main/assets/Themes/DefaultList/variables.xml @@ -0,0 +1,4 @@ + + + + diff --git a/app/src/main/assets/Themes/HeroCarousel/manifest.xml b/app/src/main/assets/Themes/HeroCarousel/manifest.xml new file mode 100644 index 000000000..b85cc89a5 --- /dev/null +++ b/app/src/main/assets/Themes/HeroCarousel/manifest.xml @@ -0,0 +1,7 @@ + + HeroCarousel + 1.0.0 + 1 + 0.0.0 + + diff --git a/app/src/main/assets/Themes/HeroCarousel/theme.xml b/app/src/main/assets/Themes/HeroCarousel/theme.xml new file mode 100644 index 000000000..ff77a1677 --- /dev/null +++ b/app/src/main/assets/Themes/HeroCarousel/theme.xml @@ -0,0 +1,37 @@ + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/java/app/gamenative/PluviaApp.kt b/app/src/main/java/app/gamenative/PluviaApp.kt index afc59ad3a..d1ffdecd3 100644 --- a/app/src/main/java/app/gamenative/PluviaApp.kt +++ b/app/src/main/java/app/gamenative/PluviaApp.kt @@ -64,6 +64,9 @@ class PluviaApp : SplitCompatApplication() { // Init our datastore preferences. PrefManager.init(this) + // Initialize ThemeManager (scan themes, migrate old layout to theme, and activate) + app.gamenative.theme.ThemeManager.init(this) + DownloadService.populateDownloadService(this) appScope.launch { diff --git a/app/src/main/java/app/gamenative/PrefManager.kt b/app/src/main/java/app/gamenative/PrefManager.kt index 99296aecc..998dc4066 100644 --- a/app/src/main/java/app/gamenative/PrefManager.kt +++ b/app/src/main/java/app/gamenative/PrefManager.kt @@ -782,4 +782,22 @@ object PrefManager { var appLanguage: String get() = getPref(APP_LANGUAGE, "") set(value) = setPref(APP_LANGUAGE, value) + + // Active Theme Engine theme id (for ThemeManager) + private val ACTIVE_THEME_ID = stringPreferencesKey("active_theme_id") + var activeThemeId: String + get() = getPref(ACTIVE_THEME_ID, "") + set(value) = setPref(ACTIVE_THEME_ID, value) + + // One-time migration flag: legacy Library layout -> ThemeManager theme id + private val THEME_LAYOUT_MIGRATED = booleanPreferencesKey("theme_layout_migrated") + var themeLayoutMigrated: Boolean + get() = getPref(THEME_LAYOUT_MIGRATED, false) + set(value) = setPref(THEME_LAYOUT_MIGRATED, value) + + // Experimental: Use Theme Engine UI for Library screen + private val USE_THEME_ENGINE_UI = booleanPreferencesKey("use_theme_engine_ui") + var useThemeEngineUi: Boolean + get() = getPref(USE_THEME_ENGINE_UI, false) + set(value) = setPref(USE_THEME_ENGINE_UI, value) } diff --git a/app/src/main/java/app/gamenative/theme/ThemeManager.kt b/app/src/main/java/app/gamenative/theme/ThemeManager.kt new file mode 100644 index 000000000..21da52b1e --- /dev/null +++ b/app/src/main/java/app/gamenative/theme/ThemeManager.kt @@ -0,0 +1,419 @@ +package app.gamenative.theme + +import android.content.Context +import android.widget.Toast +import app.gamenative.BuildConfig +import app.gamenative.PrefManager +import app.gamenative.R +import app.gamenative.theme.io.ThemeLoader +import app.gamenative.theme.io.ThemeXmlMapper +import app.gamenative.theme.model.ThemeDefinition +import app.gamenative.theme.model.ThemeEngine +import app.gamenative.theme.model.ThemeLoadResult +import app.gamenative.theme.validate.ThemeValidator +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import org.xmlpull.v1.XmlPullParser +import org.xmlpull.v1.XmlPullParserFactory +import timber.log.Timber +import java.io.File +import java.io.InputStream +import java.nio.file.Paths + +/** + * ThemeManager: enumerates themes (built-in assets and user directory), persists selection, + * loads manifests with compatibility checks, and exposes a dev-only hot reload. + * + * This step keeps integration minimal: we parse only the manifest and notify listeners + * when selection or reload occurs. Rendering hookup will follow in later steps. + */ +object ThemeManager { + + enum class Source { BuiltIn, User } + + data class ThemeEntry( + val id: String, + val name: String, // for now same as id; can be extended later + val source: Source, + val location: String, // folder path or asset subfolder + val manifest: ManifestLite, + ) + + data class ManifestLite( + val id: String, + val version: String, + val engineVersion: Int, + val minAppVersion: String, + val maxAppVersion: String?, + ) + + private lateinit var appCtx: Context + + private val scope = CoroutineScope(Dispatchers.Main) + + private val _availableThemes = MutableStateFlow>(emptyList()) + val availableThemes: StateFlow> = _availableThemes.asStateFlow() + + private val _selectedThemeId = MutableStateFlow(null) + val selectedThemeId: StateFlow = _selectedThemeId.asStateFlow() + + // Emit a monotonically increasing token whenever we re-parse the active theme (dev-only reload) + private val _reloadTick = MutableStateFlow(0) + val reloadTick: StateFlow = _reloadTick.asStateFlow() + + private val _activeTheme = MutableStateFlow(null) + val activeTheme: StateFlow = _activeTheme.asStateFlow() + + private const val ASSETS_THEMES_ROOT = "Themes" + private const val FALLBACK_THEME_ID = "DefaultList" + + fun init(context: Context) { + appCtx = context.applicationContext + // Perform one-time migration from legacy layout preference to theme id, before scanning + migrateLegacyLayoutToThemeIfNeeded() + // Initial scan + resolve selection + scope.launch(Dispatchers.IO) { + val list = scanAllThemes() + _availableThemes.value = list + val persisted = PrefManager.activeThemeId.ifBlank { FALLBACK_THEME_ID } + val chosen = list.find { it.id.equals(persisted, ignoreCase = true) } + ?: pickFallback(list) + _selectedThemeId.value = chosen?.id + if (chosen == null) { + Timber.w("No themes found; ThemeManager availableThemes is empty") + } else { + // Load full theme definition + loadAndActivateTheme(chosen) + } + } + } + + /** + * One-time migration: map old Library layout preference to a Theme id. + * This only sets PrefManager.activeThemeId if not already chosen and if migration wasn't done. + */ + private fun migrateLegacyLayoutToThemeIfNeeded() { + try { + if (PrefManager.themeLayoutMigrated) return + // If user already has an active theme set, skip + if (PrefManager.activeThemeId.isNotBlank()) { + PrefManager.themeLayoutMigrated = true + return + } + val layout = PrefManager.libraryLayout + val themeId = when (layout.name) { + // Map rough equivalents + "LIST" -> "DefaultList" + "GRID_CAPSULE" -> "CapsuleGrid" + "GRID_HERO" -> "HeroCarousel" + else -> FALLBACK_THEME_ID + } + PrefManager.activeThemeId = themeId + PrefManager.themeLayoutMigrated = true + Timber.i("Migrated legacy layout '%s' to theme id '%s'", layout.name, themeId) + } catch (t: Throwable) { + Timber.e(t, "migrateLegacyLayoutToThemeIfNeeded failed") + } + } + + fun getSelectedThemeEntry(): ThemeEntry? = _availableThemes.value.firstOrNull { it.id == _selectedThemeId.value } + + fun selectTheme(id: String) { + val match = _availableThemes.value.firstOrNull { it.id == id } + if (match == null) { + Timber.w("Attempted to select unknown theme id=%s", id) + return + } + _selectedThemeId.value = match.id + PrefManager.activeThemeId = match.id + scope.launch(Dispatchers.Main) { + Toast.makeText(appCtx, appCtx.getString(R.string.theme_applied_toast, match.id), Toast.LENGTH_SHORT).show() + } + Timber.i("Theme selected: %s (%s)", match.id, match.source) + // Load full theme definition + scope.launch(Dispatchers.IO) { + try { + loadAndActivateTheme(match) + } catch (t: Throwable) { + Timber.e(t, "Failed to activate theme after selection") + } + } + } + + fun reloadActiveThemeDevOnly() { + if (!BuildConfig.DEBUG) return + val current = getSelectedThemeEntry() ?: return + scope.launch(Dispatchers.IO) { + try { + // Re-parse manifests to reflect any changes on disk/assets + val list = scanAllThemes() + _availableThemes.value = list + val stillThere = list.find { it.id == current.id } + if (stillThere == null) { + Timber.w("Active theme missing after reload; falling back") + applyFallbackWithToast(list) + pickFallback(list)?.let { fb -> loadAndActivateTheme(fb) } + } else { + // Re-load full theme definition + loadAndActivateTheme(stillThere) + // Bump reload tick so observers can diff-apply changes if needed + _reloadTick.value = _reloadTick.value + 1 + Timber.i("Theme reloaded (dev): %s", current.id) + } + } catch (t: Throwable) { + Timber.e(t, "Reload failed; falling back") + val list2 = scanAllThemes() + applyFallbackWithToast(list2) + pickFallback(list2)?.let { fb -> loadAndActivateTheme(fb) } + } + } + } + + // --- Scanning & parsing --- + + private fun scanAllThemes(): List { + val builtIns = scanBuiltInThemes() + val users = scanUserThemes() + return (builtIns + users) + .distinctBy { it.id } + .sortedWith(compareBy { it.source }.thenBy { it.id.lowercase() }) + } + + private fun scanBuiltInThemes(): List { + val results = mutableListOf() + try { + val subdirs = appCtx.assets.list(ASSETS_THEMES_ROOT) ?: emptyArray() + subdirs.forEach { dir -> + val manifestPath = "$ASSETS_THEMES_ROOT/$dir/manifest.xml" + val manifest = parseManifest(appCtx.assets.open(manifestPath)) ?: return@forEach + if (!isCompatible(manifest)) return@forEach + results += ThemeEntry( + id = manifest.id, + name = manifest.id, + source = Source.BuiltIn, + location = dir, + manifest = manifest, + ) + } + } catch (t: Throwable) { + Timber.e(t, "scanBuiltInThemes failed") + } + return results + } + + private fun scanUserThemes(): List { + val results = mutableListOf() + return try { + val root = getUserThemesRoot() ?: return emptyList() + if (!root.exists() || !root.isDirectory) return emptyList() + root.listFiles()?.forEach { dir -> + if (!dir.isDirectory) return@forEach + val manifestFile = File(dir, "manifest.xml") + if (!manifestFile.exists()) return@forEach + val manifest = manifestFile.inputStream().use { parseManifest(it) } ?: return@forEach + if (!isCompatible(manifest)) return@forEach + results += ThemeEntry( + id = manifest.id, + name = manifest.id, + source = Source.User, + location = dir.absolutePath, + manifest = manifest, + ) + } + results + } catch (t: Throwable) { + Timber.e(t, "scanUserThemes failed") + results + } + } + + private fun getUserThemesRoot(): File? { + val ext = PrefManager.externalStoragePath + if (ext.isBlank()) return null + val p = Paths.get(ext, "GameNative", "Themes").toFile() + return p + } + + private fun parseManifest(input: InputStream): ManifestLite? { + return try { + input.use { + val factory = XmlPullParserFactory.newInstance() + val parser = factory.newPullParser() + parser.setInput(it, null) + var id: String? = null + var version: String? = null + var engineVersion: Int? = null + var minAppVersion: String? = null + var maxAppVersion: String? = null + var event = parser.eventType + while (event != XmlPullParser.END_DOCUMENT) { + if (event == XmlPullParser.START_TAG) { + when (parser.name.lowercase()) { + "id" -> id = parser.nextText()?.trim() + "version" -> version = parser.nextText()?.trim() + "engineversion" -> engineVersion = parser.nextText()?.trim()?.toIntOrNull() + "minappversion" -> minAppVersion = parser.nextText()?.trim() + "maxappversion" -> maxAppVersion = parser.nextText()?.trim() + } + } + event = parser.next() + } + if (id.isNullOrBlank() || version.isNullOrBlank() || engineVersion == null || minAppVersion.isNullOrBlank()) { + Timber.w("Manifest missing required fields") + null + } else { + ManifestLite(id!!, version!!, engineVersion!!, minAppVersion!!, maxAppVersion) + } + } + } catch (t: Throwable) { + Timber.e(t, "parseManifest failed") + null + } + } + + private fun isCompatible(m: ManifestLite): Boolean { + if (m.engineVersion != ThemeEngine.ENGINE_MAJOR) { + Timber.i("Ignoring theme %s due to engineVersion=%d", m.id, m.engineVersion) + return false + } + // App version compatibility: The app doesn’t expose semantic version; accept all for now + return true + } + + private fun pickFallback(list: List): ThemeEntry? { + return list.firstOrNull { it.id.equals(FALLBACK_THEME_ID, ignoreCase = true) } + ?: list.firstOrNull { it.source == Source.BuiltIn } + ?: list.firstOrNull() + } + + private fun applyFallbackWithToast(list: List) { + val fb = pickFallback(list) + if (fb == null) { + Timber.w("No fallback theme available") + return + } + _selectedThemeId.value = fb.id + PrefManager.activeThemeId = fb.id + // Ensure toast is shown on the main thread to avoid NPE (Looper not prepared) + scope.launch(Dispatchers.Main) { + Toast.makeText(appCtx, appCtx.getString(R.string.theme_fallback_toast, fb.id), Toast.LENGTH_LONG).show() + } + Timber.i("Fell back to theme: %s", fb.id) + } + + // --- Activation --- + /** + * Load, validate, map and activate the given theme entry. + * Handles built-in assets by copying into cache first. + */ + private fun loadAndActivateTheme(entry: ThemeEntry) { + try { + val loadDir: File = when (entry.source) { + Source.User -> File(entry.location) + Source.BuiltIn -> { + // Copy built-in assets from APK to cache dir + val cacheRoot = File(appCtx.cacheDir, "themes_cache") + val dst = File(cacheRoot, entry.location) + copyAssetsThemeTo(dst, entry.location) + dst + } + } + + val loader = ThemeLoader() + when (val res = loader.load(loadDir.absolutePath)) { + is ThemeLoadResult.Success -> { + // Validate + val validation = ThemeValidator.validate( + res.tree, + appVersion = BuildConfig.VERSION_NAME, + engineMajor = ThemeEngine.ENGINE_MAJOR, + ) + if (validation.hasBlocking()) { + Timber.w("Validation failed for theme %s: %s", entry.id, validation.issues.joinToString { it.code.name }) + val all = _availableThemes.value + applyFallbackWithToast(all) + pickFallback(all)?.let { fb -> if (fb.id != entry.id) loadAndActivateTheme(fb) } + return + } + // Map to runtime model + val def: ThemeDefinition = ThemeXmlMapper.map(res.tree) + _activeTheme.value = def + Timber.i("Theme activated: %s (%s)", entry.id, entry.source) + } + is ThemeLoadResult.Failure -> { + Timber.w("Failed to load theme %s: %s", entry.id, res.errors.joinToString { it.code }) + val all = _availableThemes.value + applyFallbackWithToast(all) + pickFallback(all)?.let { fb -> if (fb.id != entry.id) loadAndActivateTheme(fb) } + } + } + } catch (t: Throwable) { + Timber.e(t, "loadAndActivateTheme crashed for %s", entry.id) + val all = _availableThemes.value + applyFallbackWithToast(all) + pickFallback(all)?.let { fb -> if (fb.id != entry.id) loadAndActivateTheme(fb) } + } + } + + // --- Assets copy helpers --- + private fun copyAssetsThemeTo(destDir: File, assetSubdir: String) { + try { + if (!destDir.exists()) destDir.mkdirs() + val base = "$ASSETS_THEMES_ROOT/$assetSubdir" + // Avoid double-nesting the top-level theme folder (destDir already points to it) + val children = appCtx.assets.list(base) ?: emptyArray() + children.forEach { child -> + val childAssetPath = "$base/$child" + val childList = try { appCtx.assets.list(childAssetPath) ?: emptyArray() } catch (_: Throwable) { emptyArray() } + if (childList.isEmpty()) { + // It's a file directly under the theme root + copyAssetFile(childAssetPath, File(destDir, child)) + } else { + // It's a subdirectory; copy recursively into destDir/child + copyAssetDirRecursive(childAssetPath, File(destDir, child)) + } + } + } catch (e: Throwable) { + Timber.e(e, "Failed to copy built-in theme assets to cache") + } + } + + private fun copyAssetDirRecursive(assetPath: String, destDir: File) { + val list = try { appCtx.assets.list(assetPath) ?: emptyArray() } catch (_: Throwable) { emptyArray() } + if (list.isEmpty()) { + // It's a file + copyAssetFile(assetPath, File(destDir, File(assetPath).name)) + return + } + // It's a directory + val dirName = assetPath.substringAfterLast('/') + val currentDest = if (dirName.isNotEmpty()) File(destDir, dirName) else destDir + if (!currentDest.exists()) currentDest.mkdirs() + list.forEach { child -> + val childAssetPath = if (assetPath.isEmpty()) child else "$assetPath/$child" + val childList = try { appCtx.assets.list(childAssetPath) ?: emptyArray() } catch (_: Throwable) { emptyArray() } + if (childList.isEmpty()) { + copyAssetFile(childAssetPath, File(currentDest, child)) + } else { + copyAssetDirRecursive(childAssetPath, currentDest) + } + } + } + + private fun copyAssetFile(assetPath: String, outFile: File) { + try { + if (!outFile.parentFile.exists()) outFile.parentFile.mkdirs() + appCtx.assets.open(assetPath).use { input -> + outFile.outputStream().use { output -> + input.copyTo(output) + } + } + } catch (e: Throwable) { + Timber.e(e, "Failed copying asset file %s", assetPath) + } + } +} diff --git a/app/src/main/java/app/gamenative/theme/io/IncludeResolver.kt b/app/src/main/java/app/gamenative/theme/io/IncludeResolver.kt new file mode 100644 index 000000000..fdcb70943 --- /dev/null +++ b/app/src/main/java/app/gamenative/theme/io/IncludeResolver.kt @@ -0,0 +1,208 @@ +package app.gamenative.theme.io + +import app.gamenative.theme.model.SourceLoc +import app.gamenative.theme.model.ThemeLoadError +import app.gamenative.theme.model.XmlNode +import java.io.File +import java.io.InputStream +import java.nio.file.Path +import java.nio.file.Paths +import javax.xml.parsers.SAXParserFactory +import org.xml.sax.Attributes +import org.xml.sax.InputSource +import org.xml.sax.Locator +import org.xml.sax.helpers.DefaultHandler + +/** + * Resolves recursively and produces an XmlNode tree while + * preserving source file and line numbers for diagnostics. + */ +class IncludeResolver( + private val themeRootDir: String, +) { + private val factory = SAXParserFactory.newInstance().apply { + isNamespaceAware = false + isValidating = false + try { setFeature("http://apache.org/xml/features/nonvalidating/load-external-dtd", false) } catch (_: Exception) {} + } + + data class Result( + val root: XmlNode?, + val errors: List, + ) + + /** + * Parse the provided file and return an XmlNode root with includes expanded. + */ + fun parseWithIncludes(filePath: String): Result { + val errors = mutableListOf() + val visited = ArrayDeque() + val root = try { + parseInternal(Paths.get(filePath).normalize(), visited, errors) + } catch (e: Exception) { + errors += ThemeLoadError( + code = "IO_PARSE_ERROR", + message = "Failed to parse XML '${filePath}': ${e.message}", + source = SourceLoc(filePath), + ) + null + } + return Result(root, errors) + } + + private fun parseInternal(file: Path, stack: ArrayDeque, errors: MutableList): XmlNode? { + val themeRoot = Paths.get(themeRootDir).normalize() + val normalized = file.normalize() + if (!normalized.startsWith(themeRoot)) { + errors += ThemeLoadError( + code = "PATH_ESCAPE", + message = "Include path escapes theme root: '${normalized}'", + source = SourceLoc(normalized.toString()), + ) + return null + } + if (!File(normalized.toString()).exists()) { + errors += ThemeLoadError( + code = "FILE_NOT_FOUND", + message = "XML file not found: '${normalized}'", + source = SourceLoc(normalized.toString()), + ) + return null + } + if (stack.contains(normalized)) { + errors += ThemeLoadError( + code = "INCLUDE_CYCLE", + message = "Cyclic include detected: ${stack.joinToString(" -> ") { it.fileName.toString() }} -> ${normalized.fileName}", + source = SourceLoc(normalized.toString()), + ) + return null + } + + stack.addLast(normalized) + val parser = factory.newSAXParser() + val handler = BuildNodeHandler(normalized.toString()) { includeSrc, includeLoc -> + // Resolve include path relative to current file + val incPath = resolvePath(normalized.parent ?: themeRoot, includeSrc) + val included = parseInternal(incPath, stack, errors) + if (included == null) { + errors += ThemeLoadError( + code = "INCLUDE_LOAD_FAILED", + message = "Failed to load include '${includeSrc}' from '${normalized.fileName}'", + source = includeLoc, + ) + emptyList() + } else { + // If included root matches parent will be unpacked by caller; here we just attach as a node + listOf(included) + } + } + File(normalized.toString()).inputStream().use { input: InputStream -> + parser.parse(InputSource(input), handler) + } + stack.removeLast() + return handler.resultRoot + } + + private fun resolvePath(baseDir: Path, raw: String): Path { + return if (raw.startsWith("/")) { + // Rooted at theme root + Paths.get(themeRootDir).resolve(raw.removePrefix("/")).normalize() + } else { + baseDir.resolve(raw).normalize() + } + } + + private class BuildNodeHandler( + private val filePath: String, + private val onInclude: (src: String, loc: SourceLoc) -> List, + ) : DefaultHandler() { + private var locator: Locator? = null + private val nodeStack = ArrayDeque() + var resultRoot: XmlNode? = null + + override fun setDocumentLocator(locator: Locator?) { + this.locator = locator + } + + override fun startElement(uri: String?, localName: String?, qName: String, attributes: Attributes) { + val name = qName + val srcLoc = SourceLoc( + filePath = filePath, + line = locator?.lineNumber, + column = locator?.columnNumber, + ) + if (name == "include") { + val includeSrc = attributes.getValue("src") + if (includeSrc != null) { + val includedNodes = onInclude(includeSrc, srcLoc) + // Attach included nodes directly into current top as children + if (nodeStack.isNotEmpty()) { + val top = nodeStack.last() + for (n in includedNodes) { + // If the included root tag matches the current parent tag, splice its children + if (n.name == top.tagName()) { + n.children.forEach { child -> top.children.add(child) } + } else { + top.children.add(n) + } + } + } else { + // If include at root level, and multiple nodes returned, wrap them under a synthetic root + // However, we do not expect include at root; ignore here (no-op) if no stack exists + } + } + // Do not push an include node itself + return + } + val attrMap = mutableMapOf() + for (i in 0 until attributes.length) { + val key = attributes.getQName(i) + val value = attributes.getValue(i) + attrMap[key] = value + } + nodeStack.addLast(XmlNodeBuilder(name, attrMap, srcLoc)) + } + + override fun characters(ch: CharArray, start: Int, length: Int) { + if (nodeStack.isEmpty()) return + val text = String(ch, start, length).takeIf { it.isNotBlank() } ?: return + nodeStack.last().appendText(text) + } + + override fun endElement(uri: String?, localName: String?, qName: String) { + if (qName == "include") return // was handled in startElement + val finished = nodeStack.removeLast() + val node = finished.build() + if (nodeStack.isEmpty()) { + resultRoot = if (resultRoot == null) node else resultRoot + } else { + val parent = nodeStack.last() + // If the node we just built is a container with same name as parent and was sourced via include, + // the unwrapping is handled in ThemeLoader when grafting. Here we keep structure as-is. + parent.children.add(node) + } + } + + private class XmlNodeBuilder( + private val name: String, + private val attributes: MutableMap, + private val source: SourceLoc, + ) { + fun tagName(): String = name + val children: MutableList = mutableListOf() + private var text: String? = null + + fun appendText(t: String) { + text = ((text ?: "") + t) + } + + fun build(): XmlNode = XmlNode( + name = name, + attributes = attributes.toMap(), + children = children.toList(), + text = text?.trim(), + source = source, + ) + } + } +} diff --git a/app/src/main/java/app/gamenative/theme/io/ThemeLoader.kt b/app/src/main/java/app/gamenative/theme/io/ThemeLoader.kt new file mode 100644 index 000000000..e3e19cff1 --- /dev/null +++ b/app/src/main/java/app/gamenative/theme/io/ThemeLoader.kt @@ -0,0 +1,223 @@ +package app.gamenative.theme.io + +import app.gamenative.theme.model.ManifestEntry +import app.gamenative.theme.model.SourceLoc +import app.gamenative.theme.model.ThemeLoadError +import app.gamenative.theme.model.ThemeLoadResult +import app.gamenative.theme.model.ThemeTree +import app.gamenative.theme.model.XmlNode +import java.io.File +import java.io.InputStream +import java.nio.file.Path +import java.nio.file.Paths +import javax.xml.parsers.SAXParserFactory +import org.xml.sax.Attributes +import org.xml.sax.InputSource +import org.xml.sax.Locator +import org.xml.sax.helpers.DefaultHandler + +/** + * Loads a Theme folder: reads manifest.xml (if present), parses theme.xml, + * resolves and , and returns a ThemeTree + * preserving file and line information for diagnostics. + */ +class ThemeLoader { + + /** Load a theme from the given folder path. */ + fun load(themeDirPath: String): ThemeLoadResult { + val errors = mutableListOf() + val themeDir = Paths.get(themeDirPath).normalize().toFile() + if (!themeDir.exists() || !themeDir.isDirectory) { + return ThemeLoadResult.Failure(listOf(ThemeLoadError( + code = "THEME_DIR_NOT_FOUND", + message = "Theme directory not found: ${themeDirPath}", + source = null, + ))) + } + + val manifestEntry = readManifestEntry(themeDir, errors) + val themeXmlPath = resolvePath(themeDir, manifestEntry?.themePath ?: "theme.xml") + val includeResolver = IncludeResolver(themeDir.absolutePath) + val parsed = includeResolver.parseWithIncludes(themeXmlPath.absolutePath) + errors += parsed.errors + val root = parsed.root + if (root == null) { + return ThemeLoadResult.Failure(errors.ifEmpty { listOf(ThemeLoadError( + code = "THEME_XML_MISSING", + message = "Failed to parse theme XML at ${themeXmlPath}", + source = SourceLoc(themeXmlPath.absolutePath), + )) }) + } + + // Gather variables: external (from manifest entry) + inline + any nodes. + val variables = LinkedHashMap() // maintain insertion order; last writer wins on put + + // 1) External variables from manifest entry + manifestEntry?.variablesPath?.let { varRel -> + val varFile = resolvePath(themeDir, varRel) + if (varFile.exists()) { + variables.putAll(parseVariablesFile(varFile, errors)) + } else { + errors += ThemeLoadError( + code = "VARIABLES_FILE_NOT_FOUND", + message = "variables.xml not found: ${varFile}", + source = SourceLoc(varFile.absolutePath), + ) + } + } + + // 2) Inline variables and variables ref inside theme tree + collectVariablesFromTree(root, themeDir, variables, errors) + + val tree = ThemeTree( + rootDir = themeDir.absolutePath, + manifestEntry = manifestEntry, + themeXml = root, + variables = variables, + ) + return if (errors.isEmpty()) ThemeLoadResult.Success(tree) else ThemeLoadResult.Failure(errors) + } + + // --- Manifest parsing --- + + private fun readManifestEntry(themeDir: File, errors: MutableList): ManifestEntry? { + val manifestFile = File(themeDir, "manifest.xml") + if (!manifestFile.exists()) return null + val factory = SAXParserFactory.newInstance().apply { + isNamespaceAware = false + isValidating = false + try { setFeature("http://apache.org/xml/features/nonvalidating/load-external-dtd", false) } catch (_: Exception) {} + } + var result: ManifestEntry? = null + try { + val parser = factory.newSAXParser() + var inManifest = false + parser.parse(InputSource(manifestFile.inputStream()), object : DefaultHandler() { + private var loc: Locator? = null + override fun setDocumentLocator(locator: Locator?) { this.loc = locator } + override fun startElement(uri: String?, localName: String?, qName: String, attributes: Attributes) { + when (qName) { + "manifest" -> inManifest = true + "entry" -> if (inManifest) { + val theme = attributes.getValue("theme") + val vars = attributes.getValue("variables") + val src = SourceLoc(manifestFile.absolutePath, loc?.lineNumber, loc?.columnNumber) + if (theme != null) { + result = ManifestEntry(themePath = theme, variablesPath = vars, source = src) + } else { + errors += ThemeLoadError( + code = "MANIFEST_ENTRY_MISSING_THEME", + message = " in manifest.xml is missing required 'theme' attribute", + source = src, + ) + } + } + } + } + }) + } catch (e: Exception) { + errors += ThemeLoadError( + code = "MANIFEST_PARSE_ERROR", + message = "Failed to parse manifest.xml: ${e.message}", + source = SourceLoc(manifestFile.absolutePath), + ) + } + return result + } + + // --- Variables parsing --- + + private fun collectVariablesFromTree(root: XmlNode, themeDir: File, out: MutableMap, errors: MutableList) { + fun traverse(node: XmlNode) { + if (node.name.equals("variables", ignoreCase = false)) { + // Load external referenced variables first (so inline can override if needed) + val ref = node.attributes["ref"] + if (!ref.isNullOrBlank()) { + val base = node.source?.filePath?.let { File(it).parentFile ?: themeDir } ?: themeDir + val refFile = if (ref.startsWith("/")) File(themeDir, ref.removePrefix("/")).canonicalFile else File(base, ref).canonicalFile + if (refFile.exists()) { + out.putAll(parseVariablesFile(refFile, errors)) + } else { + errors += ThemeLoadError( + code = "VARIABLES_REF_NOT_FOUND", + message = "Referenced variables file not found: ${ref}", + source = node.source, + ) + } + } + // Inline variable definitions + node.children.filter { it.name == "var" }.forEach { vNode -> + val name = vNode.attributes["name"] + val value = vNode.attributes["value"] ?: vNode.text + if (!name.isNullOrBlank() && value != null) { + out[name] = value + } else { + errors += ThemeLoadError( + code = "VAR_BAD_DEF", + message = " must have name and value", + source = vNode.source, + ) + } + } + } + node.children.forEach { traverse(it) } + } + traverse(root) + } + + private fun parseVariablesFile(file: File, errors: MutableList): Map { + val result = LinkedHashMap() + val factory = SAXParserFactory.newInstance().apply { + isNamespaceAware = false + isValidating = false + try { setFeature("http://apache.org/xml/features/nonvalidating/load-external-dtd", false) } catch (_: Exception) {} + } + try { + val parser = factory.newSAXParser() + var inVariables = false + parser.parse(InputSource(file.inputStream()), object : DefaultHandler() { + private var loc: Locator? = null + override fun setDocumentLocator(locator: Locator?) { this.loc = locator } + override fun startElement(uri: String?, localName: String?, qName: String, attributes: Attributes) { + when (qName) { + "variables" -> inVariables = true + "var" -> if (inVariables) { + val name = attributes.getValue("name") + val value = attributes.getValue("value") + if (!name.isNullOrBlank() && value != null) { + result[name] = value + } else { + // Try text value support via characters -> keep simple: ignored here + } + } + } + } + }) + } catch (e: Exception) { + errors += ThemeLoadError( + code = "VARIABLES_PARSE_ERROR", + message = "Failed to parse variables file '${file.name}': ${e.message}", + source = SourceLoc(file.absolutePath), + ) + } + return result + } + + // --- Utils --- + + private fun resolvePath(baseDir: File, raw: String): File { + return if (raw.startsWith("/")) { + // Leading slash means theme root + File(baseDir, ".") // normalize base + File(baseDir.absolutePath).parentFile?.let { themeRootParent -> + // But we actually have themeDir as base; we want themeDir itself. + // Simpler: compute using Paths + val root = baseDir.toPath() + val abs = root.resolve(raw.removePrefix("/")).normalize() + abs.toFile() + } ?: File(baseDir, raw.removePrefix("/")) + } else { + File(baseDir, raw).canonicalFile + } + } +} diff --git a/app/src/main/java/app/gamenative/theme/io/ThemeXmlMapper.kt b/app/src/main/java/app/gamenative/theme/io/ThemeXmlMapper.kt new file mode 100644 index 000000000..c784ed2c9 --- /dev/null +++ b/app/src/main/java/app/gamenative/theme/io/ThemeXmlMapper.kt @@ -0,0 +1,389 @@ +package app.gamenative.theme.io + +import app.gamenative.theme.model.* +import java.io.File + +/** + * Maps a merged ThemeTree (XmlNode-based) produced by ThemeLoader into the runtime ThemeDefinition model + * used by the rendering engine. This does not perform validation — call ThemeValidator first. + */ +object ThemeXmlMapper { + + /** Convert the merged [ThemeTree] into a [ThemeDefinition]. */ + fun map(tree: ThemeTree): ThemeDefinition { + val root = tree.themeXml + // Variables: best-effort mapping from loader map -> Variable entries (typed as STRING by default) + val variables = tree.variables.map { (k, v) -> + Variable(id = k, type = ValueType.STRING, defaultValue = v) + } + + val cards = parseCards(root) + val fixedContainers = parseFixedContainers(root) + val layout = parseLayout(root, tree) + val manifest = buildManifest(tree) + + return ThemeDefinition( + manifest = manifest, + variables = variables, + cards = cards, + fixedContainers = fixedContainers, + layout = layout, + ) + } + + // region Manifest + private fun buildManifest(tree: ThemeTree): Manifest { + // Try to infer id and version from manifest.xml filename and contents are already validated elsewhere. + val themeDir = File(tree.rootDir) + val id = themeDir.name.ifBlank { tree.manifestEntry?.themePath ?: "unknown" } + // We don't have parsed manifest fields here; use safe defaults (engine is authoritative) + return Manifest( + id = id, + version = "1.0.0", + engineVersion = ThemeEngine.ENGINE_MAJOR, + minAppVersion = "0.0.0", + maxAppVersion = null, + ) + } + // endregion + + // region Cards + private fun parseCards(root: XmlNode): List { + // Support both new / and legacy /