Files
memby/app/build.gradle.kts
T
ponzischeme89andClaude Opus 5 abc392d30b 0.2.62 - Roll back the custom playback engine
0.2.61 shipped the groundwork for Memby playing video itself and caused
playback problems in the field, so it is withdrawn. This is 0.2.60's code
released as 0.2.62: Android refuses an APK whose versionCode went
backwards, so a set already on 0.2.61 (20061) could not install a 0.2.60
build (20060).

The 0.2.61 work is parked on parked/0.2.61 and tagged broken-0.2.61.

Also corrects the changelog headings the reset left behind: the top block
was labelled 0.2.59 while holding 0.2.60's notes, and 0.2.59's own two
entries were folded into it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 19:28:10 +12:00

280 lines
13 KiB
Kotlin

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")
}
// 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()
// Kept in BuildConfig so the TV can show the exact corresponding-source location and
// the complete legal documents offline. Deployments can override the public source URL
// without changing application code.
val membySourceUrl: String =
(project.findProperty("memby.sourceUrl") as String?)
?.trim()
?.takeIf(String::isNotEmpty)
?: "https://g.sublogue.com/admin/memby"
fun buildConfigString(value: String): String =
"\"" + value
.replace("\\", "\\\\")
.replace("\"", "\\\"")
.replace("\r\n", "\\n")
.replace("\n", "\\n") + "\""
// The About page's version history. Kept as one checked-in document rather than a Kotlin
// list so a release only edits CHANGELOG.md, and the TV shows the history offline.
val changelogText = rootProject.file("CHANGELOG.md").readText()
val gplLicenseText = rootProject.file("LICENSE").readText()
val projectNoticeText =
rootProject.file("NOTICE").readText()
.replace("https://g.sublogue.com/admin/memby", membySourceUrl)
// A release workflow can derive the app version from its Git tag without editing the
// source tree. Local builds keep using the checked-in default.
val defaultVersionName = "0.2.62"
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() }
android {
namespace = "com.ponzischeme89.memby"
compileSdk = 35
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", "SOURCE_CODE_URL", buildConfigString(membySourceUrl))
buildConfigField("String", "GPL_LICENSE_TEXT", buildConfigString(gplLicenseText))
buildConfigField("String", "PROJECT_NOTICE_TEXT", buildConfigString(projectNoticeText))
buildConfigField("String", "CHANGELOG_TEXT", buildConfigString(changelogText))
}
// 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") ?: System.getenv("MEMBY_KEYSTORE")
val hasKeystore = !keystorePath.isNullOrBlank() && file(keystorePath).exists()
signingConfigs {
if (hasKeystore) {
create("release") {
storeFile = file(keystorePath!!)
storePassword = localProperty("memby.keystorePassword") ?: System.getenv("MEMBY_KEYSTORE_PASSWORD")
keyAlias = localProperty("memby.keyAlias") ?: System.getenv("MEMBY_KEY_ALIAS") ?: "memby"
keyPassword = localProperty("memby.keyPassword") ?: System.getenv("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 {
val composeBom = platform("androidx.compose:compose-bom:2024.12.01")
implementation(composeBom)
// Core / lifecycle / activity
implementation("androidx.core:core-ktx:1.15.0")
implementation("androidx.activity:activity-compose:1.9.3")
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.8.7")
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.8.7")
implementation("androidx.lifecycle:lifecycle-runtime-compose:2.8.7")
implementation("androidx.work:work-runtime-ktx:2.10.0")
// 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:2.8.7")
implementation("androidx.savedstate:savedstate-ktx:1.2.1")
// 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")
// Compose for TV
implementation("androidx.tv:tv-material:1.0.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.1.1")
// Media3 / ExoPlayer for in-app playback
implementation("androidx.media3:media3-exoplayer:1.5.1")
implementation("androidx.media3:media3-exoplayer-hls:1.5.1")
implementation("androidx.media3:media3-ui:1.5.1")
// 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:1.5.1")
// 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.5.x; Jellyfin's
// 1.5.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.5.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")
}