Files
minstrel/android/app/build.gradle.kts
T
bvandeusenandClaude Opus 5 b52a00df66
android / Build + lint + test (push) Successful in 4m19s
fix(android): bundle the typefaces instead of fetching them at runtime
Typography.kt resolved Fraunces, Inter and JetBrains Mono through the Play
Services font provider, which fetches them over the network on first use.
Same rule-164 problem the web client had, with a second failure mode on
top: the provider is absent entirely on devices without Play Services, so
the app fell back to the platform default and stopped looking like
Minstrel — quietly, with no error.

The five static instances now live in res/font, vendored by the same
tools/vendor-fonts.py that produces the web bundle. Both clients draw
from one list of faces so they cannot drift apart. Cost is ~0.86 MB of
APK; the runtime path is removed rather than kept as a fallback — the
ui-text-google-fonts dependency, its version-catalog entry and the
provider certificate hashes in font_certs.xml are all gone.

Two things about fetching TTFs that are worth writing down, because both
fail by succeeding:

Google Fonts picks the format from the User-Agent, and there is no
parameter to ask for one. A modern UA gets woff2, which res/font cannot
load. The obvious "use an old UA" fix gets EOT — an IE-only format that
downloads happily, has a plausible size, and is entirely useless here. An
Android 4.4 UA is what actually yields TrueType.

css2 also collapses a multi-weight request to 400 for legacy clients, so
asking for Medium silently returns Regular: a valid TrueType file that
renders at the wrong weight everywhere. Each weight is therefore fetched
on its own URL, and the script now asserts OS/2 usWeightClass on every
download — that field is the only thing distinguishing the two files.

Verified before wiring: all five carry TrueType magic, the 400/500 pairs
differ, and their usWeightClass reads 400/400/500/500/400 as declared
beside them in the FontFamily.

Not covered: there is no guard for this on the Android side. The web
equivalent is asserted by no-external-assets.test.ts, but the Android
tree has no source-inspection test pattern to follow and no way to
falsify one without a local Gradle run.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SQ31KQpYbStyK5y58UmPLH
2026-09-09 14:13:57 -04:00

226 lines
8.6 KiB
Kotlin

plugins {
alias(libs.plugins.android.application)
// kotlin-android NOT applied: AGP 9 enables built-in Kotlin by default,
// and KSP 2.3.x supports it (PR #2674, merged Oct 2025). serialization
// + compose-compiler are language-level Kotlin compiler plugins and
// still need explicit application.
alias(libs.plugins.kotlin.serialization)
alias(libs.plugins.compose.compiler)
alias(libs.plugins.ksp)
alias(libs.plugins.androidx.room)
alias(libs.plugins.hilt)
alias(libs.plugins.ktlint)
alias(libs.plugins.detekt)
}
android {
namespace = "com.fabledsword.minstrel"
compileSdk = 36
defaultConfig {
applicationId = "com.fabledsword.minstrel"
minSdk = 26
targetSdk = 36
// versionName / versionCode are released-build values injected by
// CI from the git tag + commit count. Local / debug builds fall
// back to "dev" so the About card reads honestly. Releases ship
// versionName="YYYY.MM.DD.<commits>" (e.g. "2026.06.02.142") and
// versionCode=<commits>, which is monotonic forever and lets the
// shared isVersionNewer comparator distinguish two same-day
// re-cuts (the iteration suffix differs).
val versionNameOverride =
(project.findProperty("MINSTREL_VERSION_NAME") as String?)?.takeIf { it.isNotBlank() }
val versionCodeOverride =
(project.findProperty("MINSTREL_VERSION_CODE") as String?)?.toIntOrNull()
versionCode = versionCodeOverride ?: 1
versionName = versionNameOverride ?: "dev"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables { useSupportLibrary = true }
}
// Room schema export is handled by the androidx.room Gradle plugin via
// the room {} block below — replaces the legacy
// `ksp { arg("room.schemaLocation", ...) }` pattern in Room 2.7+.
signingConfigs {
create("release") {
val keystorePath: String? = System.getenv("ANDROID_KEYSTORE_PATH")
if (!keystorePath.isNullOrEmpty()) {
storeFile = file(keystorePath)
storePassword = System.getenv("ANDROID_STORE_PASSWORD")
keyAlias = System.getenv("ANDROID_KEY_ALIAS")
keyPassword = System.getenv("ANDROID_KEY_PASSWORD")
}
}
}
buildTypes {
release {
isMinifyEnabled = false
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro",
)
signingConfig =
if (System.getenv("ANDROID_KEYSTORE_PATH").isNullOrEmpty()) {
signingConfigs.getByName("debug")
} else {
signingConfigs.getByName("release")
}
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
buildFeatures {
compose = true
buildConfig = true
}
packaging {
resources.excludes +=
setOf(
"/META-INF/{AL2.0,LGPL2.1}",
"META-INF/LICENSE.md",
"META-INF/LICENSE-notice.md",
)
}
}
// Kotlin 2.x: `kotlinOptions { ... }` inside `android { }` is gone; the
// modern shape is the top-level `kotlin { compilerOptions { ... } }` block,
// which works whether Kotlin comes from AGP 9's built-in path or an
// explicit plugin alias.
kotlin {
compilerOptions {
jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17)
// Opt into the future Kotlin behavior: annotations on
// constructor parameters apply to both the param AND the
// generated property/field. Without this flag, Kotlin 2.x
// emits a deprecation warning at every @Inject /
// @ApplicationContext / @ApplicationScope constructor-
// parameter use. Setting it now matches what becomes the
// default in Kotlin 2.3 (KT-73255).
freeCompilerArgs.add("-Xannotation-default-target=param-property")
}
}
// Room schema export — generated JSON dumps live under android/app/schemas/
// for migration-test fixtures. Replaces the legacy
// `ksp { arg("room.schemaLocation", ...) }` arg-passing.
room {
schemaDirectory("$projectDir/schemas")
}
detekt {
toolVersion = libs.versions.detekt.get()
config.setFrom(files("$rootDir/config/detekt.yml"))
buildUponDefaultConfig = true
parallel = true
// `autoCorrect` was dropped in detekt 2.0's DSL options list.
}
// detekt 2.0 moved its task types to the `dev.detekt.gradle` package and
// flipped `jvmTarget` to the Property API. Pin to 17 to match our actual
// bytecode target (compileOptions.targetCompatibility +
// kotlin.compilerOptions.jvmTarget).
tasks.withType<dev.detekt.gradle.Detekt>().configureEach {
jvmTarget.set("17")
}
tasks.withType<dev.detekt.gradle.DetektCreateBaselineTask>().configureEach {
jvmTarget.set("17")
}
dependencies {
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.lifecycle.runtime.compose)
implementation(libs.androidx.lifecycle.viewmodel.compose)
implementation(libs.androidx.lifecycle.process)
implementation(libs.androidx.activity.compose)
implementation(libs.androidx.nav.compose)
implementation(libs.androidx.hilt.nav.compose)
implementation(libs.androidx.hilt.work)
ksp(libs.androidx.hilt.compiler)
implementation(libs.androidx.work.runtime.ktx)
implementation(platform(libs.compose.bom))
implementation(libs.compose.ui)
implementation(libs.compose.ui.graphics)
implementation(libs.compose.material3)
debugImplementation(libs.compose.ui.tooling)
implementation(libs.compose.ui.tooling.preview)
implementation(libs.hilt.android)
ksp(libs.hilt.compiler)
implementation(libs.room.runtime)
implementation(libs.room.ktx)
ksp(libs.room.compiler)
implementation(libs.retrofit)
implementation(libs.retrofit.kotlinx.serialization.converter)
implementation(libs.okhttp)
implementation(libs.okhttp.logging)
implementation(libs.okhttp.sse)
implementation(libs.kotlinx.serialization.json)
implementation(libs.kotlinx.coroutines.android)
implementation(libs.kotlinx.datetime)
implementation(libs.media3.exoplayer)
implementation(libs.media3.session)
implementation(libs.media3.datasource.okhttp)
implementation(libs.mediarouter)
implementation(libs.coil.compose)
implementation(libs.coil.network.okhttp)
implementation(libs.androidx.palette)
implementation(libs.icons.lucide)
implementation(libs.timber)
testImplementation(libs.junit.jupiter)
testImplementation(libs.turbine)
testImplementation(libs.mockk)
testImplementation(libs.kotlinx.coroutines.test)
testImplementation(libs.okhttp.mockwebserver)
// kxml2 — provides an org.xmlpull.v1 impl on the JVM unit-test
// classpath. Android's stock XmlPullParserFactory resolves to the
// android.jar Stub on JVM tests; kxml2 is picked up via service-
// provider lookup and makes XmlPullParserFactory.newInstance() work
// unconditionally so DeviceDescriptionTest runs in CI.
testImplementation(libs.kxml2)
// kotlin.test for assertEquals/assertNull/etc. — version managed by
// the applied Kotlin plugin so no explicit version pin needed.
testImplementation(kotlin("test"))
// Gradle 9 no longer auto-injects the JUnit Platform launcher; must
// be declared explicitly on the runtime classpath for useJUnitPlatform()
// to discover tests.
testRuntimeOnly(libs.junit.platform.launcher)
androidTestImplementation(platform(libs.compose.bom))
androidTestImplementation(libs.compose.ui.test)
// androidTest dep parity with the unit-test side; needed once we have
// instrumented tests that consume the same APIs.
androidTestImplementation(kotlin("test"))
androidTestImplementation(libs.kotlinx.coroutines.test)
debugImplementation(libs.compose.ui.test.manifest)
}
tasks.withType<Test> {
useJUnitPlatform()
// Print the assertion message + full stack trace for failures. The
// default console output gives only "AssertionError at Foo.kt:12", and
// for a failure inside a `runTest { }` lambda even that line collapses
// to the test function's own line (the assertion frames live in the
// suspend-lambda class, which Gradle filters out) — leaving nothing to
// debug from when the HTML report isn't reachable, as in CI.
testLogging {
events("failed")
exceptionFormat = org.gradle.api.tasks.testing.logging.TestExceptionFormat.FULL
showStackTraces = true
}
}