Fix the crash on every cold start of a signed-in television. MembyIcons' currentIconPack and DesignTokens' activePalette are process-wide mutableStateOf. The first thing in the process to touch either was ThemeSync, applying the cached pack and palette from an IO thread within milliseconds of the settings store's first emission -- reliably after MainActivity had taken its composition snapshot. A state object created in a newer snapshot than the composition reading it makes that read throw, so HomeNavigation's first MembyIcon.mark read died with "Reading a state that was created after the snapshot was taken". MembyApp.onCreate now creates both on the main thread before anything else can. Both calls are value-level no-ops; what matters is the thread and the ordering. It was invisible on a fresh install because first-run and sign-in draw no marks, which is why clearing app data appeared to fix it -- and it looked like an update bug only because replacing the APK is what forces a cold start with the launcher as the first screen. A reboot did it too. Also: AppRoot filtered neither read of the persisted requiredUpdateVersion through requiredUpdateSatisfied, so a set that had just installed the demanded version opened on the retired-build screen it was updating to escape, until a /v1/update round trip completed. Both reads now apply the rule the in-memory signal and markRequiredUpdate already did. Verified on 10.0.0.238: three cold starts, no crash, launcher fully drawn. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HoEWidWhZu9kZzNURatmcT
314 lines
16 KiB
Kotlin
314 lines
16 KiB
Kotlin
import com.android.build.api.dsl.ApplicationExtension
|
|
import java.util.Properties
|
|
|
|
plugins {
|
|
id("com.android.application")
|
|
id("org.jetbrains.kotlin.android")
|
|
id("org.jetbrains.kotlin.plugin.compose")
|
|
id("org.jetbrains.kotlin.plugin.serialization")
|
|
id("androidx.baselineprofile")
|
|
}
|
|
|
|
// One Media3 version for every artifact in the build — see the dependency block for why
|
|
// the Jellyfin FFmpeg extension pins which line that can be.
|
|
val media3Version = "1.9.4"
|
|
|
|
// Lifecycle and Activity move as one AndroidX stack: activity-compose depends on the
|
|
// lifecycle artifacts, and lifecycle-runtime-compose depends on activity's ComponentActivity
|
|
// contract, so a split upgrade resolves to a mixture Gradle picked rather than one the
|
|
// libraries were tested as. Keep these two in step.
|
|
val lifecycleVersion = "2.11.0"
|
|
val activityVersion = "1.13.0"
|
|
|
|
// The icon packs a server-driven theme may name. One version across every pack: the
|
|
// receiver objects (Lucide, FontAwesome.Solid) come from shared base artifacts, so a split
|
|
// version resolves two copies of the same object and the extension properties stop
|
|
// matching their receiver.
|
|
val composeIconsVersion = "2.2.1"
|
|
|
|
// Set in gradle.properties (or ~/.gradle/gradle.properties, or -Pmemby.serverUrl=...).
|
|
// Blank means "no hardwired server": the setup screen asks the user for an address.
|
|
val embyServerUrl: String = (project.findProperty("memby.serverUrl") as String?).orEmpty().trim()
|
|
|
|
// The Memby gateway container. When set, the client talks to it instead of Emby and
|
|
// becomes a thin renderer; blank keeps the direct-to-Emby path above.
|
|
val membyGatewayUrl: String = (project.findProperty("memby.gatewayUrl") as String?).orEmpty().trim()
|
|
// Diagnostic verbosity is a build-time switch so a support APK can capture deep network
|
|
// and Media3 state without changing call sites. Valid values: INFO, DEBUG, TRACE.
|
|
val membyDiagnosticLogLevel: String = (project.findProperty("memby.diagnosticLogLevel") as String?)
|
|
?.trim()?.uppercase()?.takeIf { it in setOf("INFO", "DEBUG", "TRACE") } ?: "INFO"
|
|
|
|
val defaultVersionName = "0.3.17"
|
|
val membyVersionName: String =
|
|
(project.findProperty("memby.versionName") as String?)
|
|
?.trim()
|
|
?.takeIf { it.matches(Regex("""\d+\.\d+\.\d+""")) }
|
|
?: defaultVersionName
|
|
val membyVersionParts = membyVersionName.split('.').map(String::toInt)
|
|
val membyVersionCode =
|
|
membyVersionParts[0] * 10_000 + membyVersionParts[1] * 100 + membyVersionParts[2]
|
|
|
|
/** Reads a key from local.properties, which is gitignored and holds machine secrets. */
|
|
val localProperties = Properties().apply {
|
|
val file = rootProject.file("local.properties")
|
|
if (file.exists()) {
|
|
file.inputStream().use { load(it) }
|
|
}
|
|
}
|
|
|
|
fun localProperty(key: String): String? =
|
|
localProperties.getProperty(key)?.trim()?.takeIf { it.isNotEmpty() }
|
|
|
|
// Compose mounts release credentials as files under /run/secrets. Supporting the
|
|
// conventional *_FILE form keeps those values out of the container configuration;
|
|
// the direct environment-variable form remains for the existing PowerShell scripts.
|
|
fun environmentSecret(key: String): String? {
|
|
val path = System.getenv("${key}_FILE")?.trim().orEmpty()
|
|
if (path.isNotEmpty()) {
|
|
return rootProject.file(path).readText().trim().takeIf { it.isNotEmpty() }
|
|
}
|
|
return System.getenv(key)?.trim()?.takeIf { it.isNotEmpty() }
|
|
}
|
|
|
|
extensions.configure<ApplicationExtension> {
|
|
namespace = "com.ponzischeme89.memby"
|
|
// Raised to 37 by the Lifecycle 2.11 / Activity 1.13 upgrade, which refuse to be
|
|
// consumed by a project compiled against anything older. This is a *compile* target
|
|
// only — targetSdk stays 35, so no new runtime behaviour is opted into and the
|
|
// televisions this ships to behave exactly as before.
|
|
compileSdk = 37
|
|
|
|
defaultConfig {
|
|
// Matches the Kotlin package. Changed from com.mattcohen.embyclientsname at
|
|
// v0.1.53: a new applicationId installs as a separate app, so that release
|
|
// required uninstalling the old one and signing in again.
|
|
applicationId = "com.ponzischeme89.memby"
|
|
minSdk = 23
|
|
targetSdk = 35
|
|
// Derived from one version string so CI cannot publish a versionName/versionCode
|
|
// pair that Android later refuses to install.
|
|
versionCode = membyVersionCode
|
|
versionName = membyVersionName
|
|
|
|
// Every native library in this APK is packaged uncompressed (minSdk 23 means
|
|
// extractNativeLibs=false), so an ABI nobody runs is dead weight carried through
|
|
// every sideload. Android TV is ARM: the x86 slices existed only for the emulator,
|
|
// which is not how this app is ever tested. Removing them is not a compatibility
|
|
// decision to revisit — adding a native dependency is.
|
|
ndk {
|
|
abiFilters += listOf("arm64-v8a", "armeabi-v7a")
|
|
}
|
|
|
|
buildConfigField("String", "EMBY_SERVER_URL", "\"${embyServerUrl.replace("\"", "\\\"")}\"")
|
|
buildConfigField("String", "MEMBY_GATEWAY_URL", "\"${membyGatewayUrl.replace("\"", "\\\"")}\"")
|
|
buildConfigField("String", "DIAGNOSTIC_LOG_LEVEL", "\"$membyDiagnosticLogLevel\"")
|
|
}
|
|
|
|
// Release signing. Android identifies an app by (applicationId, signing key), so
|
|
// every update must be signed with the SAME key — a different one is rejected as a
|
|
// different app and forces users to uninstall first. Keep the keystore and these
|
|
// credentials off the repo: set them in local.properties (gitignored) or the
|
|
// environment. Without them, `assembleRelease` still builds but stays unsigned.
|
|
val keystorePath = localProperty("memby.keystore") ?: environmentSecret("MEMBY_KEYSTORE")
|
|
val hasKeystore = !keystorePath.isNullOrBlank() && file(keystorePath).exists()
|
|
|
|
signingConfigs {
|
|
if (hasKeystore) {
|
|
create("release") {
|
|
storeFile = file(keystorePath!!)
|
|
storePassword = localProperty("memby.keystorePassword") ?: environmentSecret("MEMBY_KEYSTORE_PASSWORD")
|
|
keyAlias = localProperty("memby.keyAlias") ?: environmentSecret("MEMBY_KEY_ALIAS") ?: "memby"
|
|
keyPassword = localProperty("memby.keyPassword") ?: environmentSecret("MEMBY_KEY_PASSWORD")
|
|
}
|
|
}
|
|
}
|
|
|
|
buildTypes {
|
|
release {
|
|
// R8 is the single biggest cold-start lever on the weak TV boxes this ships
|
|
// to: a smaller dex is less to load and verify before the first frame. The
|
|
// keep rules in proguard-rules.pro are what stop it stripping the
|
|
// kotlinx.serialization models the gateway contract depends on — if you add a
|
|
// @Serializable package, add it there too.
|
|
isMinifyEnabled = true
|
|
isShrinkResources = true
|
|
proguardFiles(
|
|
getDefaultProguardFile("proguard-android-optimize.txt"),
|
|
"proguard-rules.pro"
|
|
)
|
|
if (hasKeystore) {
|
|
signingConfig = signingConfigs.getByName("release")
|
|
} else {
|
|
logger.warn(
|
|
"Memby: no release keystore configured (memby.keystore). " +
|
|
"assembleRelease will produce an UNSIGNED apk that cannot be installed. " +
|
|
"See README > Distributing builds.",
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
compileOptions {
|
|
sourceCompatibility = JavaVersion.VERSION_17
|
|
targetCompatibility = JavaVersion.VERSION_17
|
|
}
|
|
|
|
buildFeatures {
|
|
compose = true
|
|
buildConfig = true
|
|
}
|
|
|
|
lint {
|
|
// media3 marks some APIs (e.g. PlayerView) with a Lint-based @UnstableApi
|
|
// opt-in check; don't let it fail the build.
|
|
abortOnError = false
|
|
}
|
|
|
|
testOptions {
|
|
unitTests {
|
|
// Robolectric needs the merged resources to inflate anything; only the
|
|
// screenshot tests use them.
|
|
isIncludeAndroidResources = true
|
|
|
|
// Roborazzi writes PNGs only in record mode. These images are artifacts to
|
|
// look at, not checked-in goldens to diff against, so recording is always on
|
|
// — a screenshot test that silently captures nothing is worse than none.
|
|
all {
|
|
it.systemProperty("roborazzi.test.record", "true")
|
|
}
|
|
}
|
|
}
|
|
|
|
packaging {
|
|
resources {
|
|
excludes += "/META-INF/{AL2.0,LGPL2.1}"
|
|
}
|
|
}
|
|
}
|
|
|
|
// Outside `android { }` on purpose: the Kotlin compiler's own settings belong to the Kotlin
|
|
// plugin, not to AGP's extension, which is why `kotlinOptions` inside it is deprecated.
|
|
// Keep in step with compileOptions above — a jvmTarget below the Java target fails the
|
|
// build the moment Kotlin has to read a class Java compiled.
|
|
kotlin {
|
|
compilerOptions {
|
|
jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17)
|
|
}
|
|
}
|
|
|
|
dependencies {
|
|
// Moved with the Lifecycle 2.11 / Activity 1.13 upgrade, which depend on Compose 1.11
|
|
// and so resolve past whatever this BOM says. Left at 2024.12.01 the BOM stopped being
|
|
// a floor OR a ceiling: Gradle picked ui 1.11.0 beside foundation 1.10.3, which is a
|
|
// Compose pair nobody published or tested together. The BOM's whole job is that the
|
|
// artifacts move as one set, so it has to keep up with what depends on them.
|
|
val composeBom = platform("androidx.compose:compose-bom:2026.06.01")
|
|
implementation(composeBom)
|
|
|
|
// Core / lifecycle / activity
|
|
implementation("androidx.core:core-ktx:1.15.0")
|
|
implementation("androidx.activity:activity-compose:$activityVersion")
|
|
implementation("androidx.lifecycle:lifecycle-runtime-ktx:$lifecycleVersion")
|
|
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:$lifecycleVersion")
|
|
implementation("androidx.lifecycle:lifecycle-runtime-compose:$lifecycleVersion")
|
|
implementation("androidx.work:work-runtime-ktx:2.11.2")
|
|
// ProcessLifecycleOwner: lets the status poll stop while no Memby screen is on top,
|
|
// instead of hitting the gateway every ten seconds for as long as the process lives.
|
|
implementation("androidx.lifecycle:lifecycle-process:$lifecycleVersion")
|
|
// Pulled to 1.4.0 by lifecycle 2.11 regardless; declared at the resolved version so the
|
|
// build file states what actually ships.
|
|
implementation("androidx.savedstate:savedstate-ktx:1.4.0")
|
|
// Measurement only: JankStats is enabled by PerformanceMonitor for debug builds.
|
|
implementation("androidx.metrics:metrics-performance:1.0.0")
|
|
// Installs the baseline profile below. Without it the profile is only honoured on
|
|
// API 31+; a TV box on Android 9-11 — most of the installed base — would get nothing.
|
|
implementation("androidx.profileinstaller:profileinstaller:1.4.1")
|
|
// Names the playback launch phases in a systrace so :benchmark can measure them.
|
|
// Free when tracing is off, which is every build a viewer ever runs.
|
|
implementation("androidx.tracing:tracing-ktx:1.2.0")
|
|
|
|
// Compose (versions from BOM)
|
|
implementation("androidx.compose.ui:ui")
|
|
implementation("androidx.compose.ui:ui-tooling-preview")
|
|
implementation("androidx.compose.foundation:foundation")
|
|
implementation("androidx.compose.material:material-icons-extended")
|
|
|
|
// Server-driven icon packs. A theme names a pack (ui/theme/MembyIconPacks.kt) and the
|
|
// television paints its marks from it, so a household can be moved off Material's
|
|
// marks without an APK release — the same trade the palette makes.
|
|
//
|
|
// Pure Kotlin ImageVectors, one lazy val per icon, so R8 keeps only the ~70 slots the
|
|
// packs below actually name. No .so, no per-ABI multiplier: this is a size decision,
|
|
// but a small one, unlike the libmpv episode recorded further down this file.
|
|
implementation("com.composables:icons-lucide-cmp:$composeIconsVersion")
|
|
implementation("com.composables:icons-font-awesome-solid-cmp:$composeIconsVersion")
|
|
|
|
// Compose for TV
|
|
implementation("androidx.tv:tv-material:1.1.0")
|
|
// Android TV's system-owned Watch Next row and optional app-owned home channels.
|
|
implementation("androidx.tvprovider:tvprovider:1.1.0")
|
|
|
|
// Image loading
|
|
implementation("io.coil-kt:coil-compose:2.7.0")
|
|
|
|
// Networking + JSON
|
|
implementation("com.squareup.retrofit2:retrofit:2.11.0")
|
|
implementation("com.squareup.okhttp3:okhttp:4.12.0")
|
|
implementation("com.squareup.okhttp3:logging-interceptor:4.12.0")
|
|
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3")
|
|
implementation("com.jakewharton.retrofit:retrofit2-kotlinx-serialization-converter:1.0.0")
|
|
|
|
// DataStore (persisted settings)
|
|
implementation("androidx.datastore:datastore-preferences:1.2.1")
|
|
|
|
// Media3 / ExoPlayer for in-app playback
|
|
// One version for the whole Media3 line, including the Jellyfin FFmpeg extension below.
|
|
// That extension is compiled against media3-exoplayer and reached reflectively through
|
|
// EXTENSION_RENDERER_MODE_ON, so a core built from a different minor line fails at
|
|
// renderer construction rather than at compile time — and PlayerEngine's LinkageError
|
|
// fallback would swallow it, silently withdrawing surround software decode with nothing
|
|
// in the log to say why. Jellyfin publishes up to the 1.9 line, so that is the line this
|
|
// app is on; moving the core past it means finding a matching extension first.
|
|
implementation("androidx.media3:media3-exoplayer:$media3Version")
|
|
implementation("androidx.media3:media3-exoplayer-hls:$media3Version")
|
|
implementation("androidx.media3:media3-ui:$media3Version")
|
|
// Lets the player pull its bytes through the app's one OkHttp stack instead of
|
|
// media3's own HttpURLConnection client — see ui/player/PlayerEngine.kt.
|
|
implementation("androidx.media3:media3-datasource-okhttp:$media3Version")
|
|
|
|
// Moonfin's Android TV backend keeps a software audio renderer behind Media3 so a
|
|
// surround track that is not bitstreamed is decoded to PCM instead of making the
|
|
// server re-encode the video beside it. This build targets Media3 1.9.x; Jellyfin's
|
|
// 1.9.0 extension is the matching published binary for that line.
|
|
//
|
|
// This is the ONLY native dependency the app carries, and it is 1.5MB per ABI because
|
|
// it links just the audio decoders it needs. It replaced a libmpv software-decoding
|
|
// fallback (io.github.abdallahmehiz:mpv-android-lib) that shipped a whole FFmpeg,
|
|
// libc++ and a 6MB subtitle font — 155MB of native code across four ABIs, which took
|
|
// the release APK from 3.1MB to 168MB and made every sideload a several-minute
|
|
// affair. A last-resort video path is not worth fifty times the app. If one is wanted
|
|
// again, it belongs behind a separately downloaded split, not in the base APK.
|
|
implementation("org.jellyfin.media3:media3-ffmpeg-decoder:1.9.0+1")
|
|
|
|
debugImplementation("androidx.compose.ui:ui-tooling")
|
|
|
|
// Ahead-of-time compiles the startup + first-scroll path. Regenerate against a real
|
|
// TV with `.\gradlew.bat :app:generateReleaseBaselineProfile`; the result is checked
|
|
// in under app/src/release/generated/baselineProfiles so ordinary release builds
|
|
// don't need a device.
|
|
baselineProfile(project(":benchmark"))
|
|
|
|
testImplementation("junit:junit:4.13.2")
|
|
|
|
// Screenshot rendering only. Everything else under app/src/test stays plain JUnit
|
|
// with no Android on the classpath — see the note in CLAUDE.md. Rendering a
|
|
// composable is the one thing that genuinely cannot be done that way, and these are
|
|
// confined to *ScreenshotTest.kt files.
|
|
testImplementation("org.robolectric:robolectric:4.14.1")
|
|
testImplementation("androidx.test.ext:junit:1.2.1")
|
|
testImplementation("io.github.takahirom.roborazzi:roborazzi:1.32.2")
|
|
testImplementation("io.github.takahirom.roborazzi:roborazzi-compose:1.32.2")
|
|
testImplementation("androidx.compose.ui:ui-test-junit4")
|
|
debugImplementation("androidx.compose.ui:ui-test-manifest")
|
|
}
|