import java.io.ByteArrayOutputStream import java.time.Instant import java.time.ZoneOffset import java.time.format.DateTimeFormatter /** * The commit this APK was built from. * * Exists so "which build am I running?" is answerable from the phone rather than by trusting * that a publish worked -- a cached download looks identical to a failed publish. */ fun gitDescribe(): String = try { val out = ByteArrayOutputStream() providers.exec { commandLine("git", "describe", "--always", "--dirty", "--abbrev=8") }.standardOutput.asText.get().trim().ifEmpty { "unknown" } } catch (e: Exception) { "unknown" } plugins { id("com.android.application") id("org.jetbrains.kotlin.android") id("org.jetbrains.kotlin.plugin.serialization") id("org.jetbrains.kotlin.plugin.compose") id("com.google.devtools.ksp") id("io.gitlab.arturbosch.detekt") id("jacoco") id("com.chaquo.python") } android { // Room schema JSON, so migrations can be tested against a real prior schema. ksp { arg("room.schemaLocation", "$projectDir/schemas") } // MigrationTestHelper reads the exported schemas off the device, so they have to ship with // the instrumented test APK. Without this the migration test fails with a missing-asset // error that does not name the schema it wanted. sourceSets.getByName("androidTest").assets.srcDir("$projectDir/schemas") namespace = "org.terst.listen" compileSdk = 36 defaultConfig { applicationId = "org.terst.listen" // Deliberately not FeeDreader's minSdk. That value is 36 in its working tree, which // would restrict the app to Android 16 and newer; 29 covers any phone in real use and // is comfortably above Media3's floor of 21. minSdk = 29 targetSdk = 36 versionCode = 1 versionName = "1.0" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" buildConfigField("String", "GIT_COMMIT", "\"${gitDescribe()}\"") buildConfigField( "String", "BUILD_TIME", "\"${DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm") .withZone(ZoneOffset.UTC).format(Instant.now())}\"", ) ndk { // CPython ships as native code, so every ABI included multiplies the APK size. // arm64 is the phone; x86_64 is the emulator. // // `-PphoneOnly` drops the emulator's copy: measured at 114MB -> 99MB, so 15MB and // not the halving it looks like it should be. The APK is dominated by 72MB of // unminified debug dex, with Chaquopy's assets and one ABI's CPython adding ~25MB // between them; shrinking it meaningfully means R8 on a debug build, not ABIs. // // Not the default: a build with no flag has to stay installable on the emulator too, // or it stops being usable by whoever forgets. Pass it for a build only the phone // will ever install. abiFilters += if (project.hasProperty("phoneOnly")) { listOf("arm64-v8a") } else { listOf("arm64-v8a", "x86_64") } } } signingConfigs { // A checked-in debug key, deliberately. // // Android generates a per-machine debug keystore, so a build from tungsten and a build // from the laptop are signed differently and each refuses to install over the other -- // "App not installed", with the only remedy being an uninstall that destroys every // playback position. Two machines build this app, so the key has to be shared. // // This is tungsten's existing debug keystore rather than a fresh one, chosen because // that is the key already installed on the phone -- a new key would have forced a // second uninstall and a second loss of every playback position. // // Safe to commit: it signs debug builds of a personal sideloaded app and nothing else. // It must never be used for a Play release, where the signing key is the app's identity. getByName("debug") { storeFile = file("$rootDir/keystore/listen-debug.keystore") // Android's standard debug credentials: this IS the platform debug keystore, // copied in so both machines sign identically. Keeping the stock alias and // password means an APK built here is byte-compatible with one built by a // default Android toolchain on any other machine. storePassword = "android" keyAlias = "androiddebugkey" keyPassword = "android" } } buildTypes { debug { enableUnitTestCoverage = true // No enableAndroidTestCoverage. Turning it on to feed instrumented data into a // coverage report instruments every class in the debug build: measured, it added // 25MB to the APK (118MB -> 143MB) and runtime probes to code Peter actually uses. // That is a real cost on every sideload for a report that would be run rarely, so // the framework-adapter exclusions carry this on their own. } release { isMinifyEnabled = false } } buildFeatures { compose = true buildConfig = true } compileOptions { sourceCompatibility = JavaVersion.VERSION_11 targetCompatibility = JavaVersion.VERSION_11 } kotlinOptions { jvmTarget = "11" } testOptions { unitTests { // Room's and Media3's Android stubs otherwise throw on every call from a JVM test. isReturnDefaultValues = true } } } dependencies { val composeBom = platform("androidx.compose:compose-bom:2026.06.01") implementation(composeBom) androidTestImplementation(composeBom) implementation("androidx.core:core-ktx:1.17.0") implementation("androidx.activity:activity-compose:1.11.0") implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.9.4") implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.9.4") implementation("androidx.navigation:navigation-compose:2.9.5") implementation("androidx.compose.ui:ui") implementation("androidx.compose.ui:ui-graphics") implementation("androidx.compose.ui:ui-tooling-preview") implementation("androidx.compose.material3:material3") implementation("androidx.compose.material:material-icons-extended") // Artwork loading. Wired to the app's own OkHttpClient (see AppGraph.imageLoader) // rather than Coil's default, so image fetches share connection pooling and timeouts // with the rest of the app. Pinned to 3.3.0: 3.4.0+ requires a newer Kotlin stdlib than // this project's 2.2.21, and 3.6.0+ pulls Compose 1.12, which needs compileSdk 37 -- // one above the max AGP 8.13.2 supports. val coil = "3.3.0" implementation("io.coil-kt.coil3:coil-compose:$coil") implementation("io.coil-kt.coil3:coil-network-okhttp:$coil") // Drag-to-reorder for the queue screen, backing QueueOrdering.move. implementation("sh.calvin.reorderable:reorderable:3.1.0") // Media3 is the whole playback stack: ExoPlayer for decode and speed control, // MediaSessionService for lock-screen and Bluetooth transport, and its DownloadManager, // which shares a cache with playback so a partly-downloaded episode streams the remainder // instead of failing. val media3 = "1.8.0" implementation("androidx.media3:media3-exoplayer:$media3") implementation("androidx.media3:media3-session:$media3") implementation("androidx.media3:media3-ui:$media3") implementation("androidx.media3:media3-datasource-okhttp:$media3") implementation("androidx.room:room-runtime:2.8.4") implementation("androidx.room:room-ktx:2.8.4") ksp("androidx.room:room-compiler:2.8.4") implementation("androidx.work:work-runtime-ktx:2.10.5") implementation("androidx.datastore:datastore-preferences:1.1.7") implementation("com.squareup.okhttp3:okhttp:4.12.0") // 1.8.1 rather than something older because Room 2.8's schema-bundle parser -- the thing // MigrationTest leans on -- calls a GeneratedSerializer method that 1.7.3 does not have, // and fails at runtime with an AbstractMethodError rather than at build time. AGP resolves // the androidTest classpath consistently with the app's, so the floor has to be raised // here; raising it in androidTestImplementation alone has no effect. implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.8.1") implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.0") // Bridges suspend functions to the Guava ListenableFuture that Media3's browse callbacks // return. kotlinx-coroutines-jdk8's future builder produces a CompletableFuture, which is // the wrong type here. implementation("org.jetbrains.kotlinx:kotlinx-coroutines-guava:1.8.0") testImplementation("junit:junit:4.13.2") testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.8.0") testImplementation("androidx.room:room-testing:2.8.4") // The JVM has no XmlPullParser implementation; the feed parser needs a real one under test. testImplementation("net.sf.kxml:kxml2:2.3.0") testImplementation("xmlpull:xmlpull:1.1.3.1") testImplementation("com.squareup.okhttp3:mockwebserver:4.12.0") testImplementation("androidx.test:core:1.7.0") androidTestImplementation("androidx.test.ext:junit:1.3.0") androidTestImplementation("androidx.test.espresso:espresso-core:3.7.0") androidTestImplementation("androidx.compose.ui:ui-test-junit4") androidTestImplementation("androidx.room:room-testing:2.8.4") androidTestImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.8.0") debugImplementation("androidx.compose.ui:ui-tooling") debugImplementation("androidx.compose.ui:ui-test-manifest") } chaquopy { defaultConfig { // Not the default 3.8. yt-dlp dropped Python 3.8 support, so pip silently resolves the // last release that still supported it -- 2024.10.22 -- which is stale enough that // YouTube extraction no longer works. The build succeeds and the feature doesn't. version = "3.12" // The extractor itself. yt-dlp is pure Python and updates independently of this app, // which is the entire reason for embedding it rather than reimplementing YouTube's // stream resolution against a target that changes without notice. pip { install("yt-dlp") } } } detekt { buildUponDefaultConfig = true config.setFrom(files("$rootDir/config/detekt/detekt.yml")) baseline = file("$rootDir/config/detekt/baseline.xml") } jacoco { toolVersion = "0.8.12" } /** * What coverage does not count. * * Compose screens are excluded for the same reason as FeeDreader: they are impractical to * unit-test and including them makes the metric meaningless. * * They are named one screen file at a time rather than by excluding the whole `ui` package, * which is what this used to do. That blanket pattern also hid six view models -- plain Kotlin * classes with real decisions in them, every one measuring 0% and not one of them a Compose * screen -- behind a justification that only ever applied to the screens. The list below is * longer, and that is its honest length: each glob names the classes one screen file compiles * to (its `...Kt` class, its `ComposableSingletons` holder, and the state and action objects * only that screen constructs). Nothing else under `ui` is excluded. * * The action and state objects are excluded on a different ground from the screens, and it is * worth being explicit so the list does not grow by analogy. They are not hard to execute off a * device -- `EpisodeActions(play = {}, ...)` constructs fine. They are excluded because every * line jacoco counts in them is one Kotlin generated: the constructor, `componentN`, `copy`, * `equals`, `hashCode`, `toString`. There is no statement of this app's own in any of them, so * measuring them measures the compiler. **That is the whole of the reason**, and it does not * extend to a data class that gains a method, an `init` block or a default computed from * another field -- the moment one does, it comes off this list. * * Two entries need saying out loud. The BreakJudgingKt glob excludes only the composables in * BreakJudging.kt; the `BreakJudging` and `BreakJudgingSource` classes beside them stay * measured. And the EpisodeScreenKt glob takes the tested `stripHtml` out of the measurement * along with the screen it happens to live in -- a cost of excluding by file, not a reason to * stop testing it. * * The rest are framework adapters -- classes whose whole body is a call into ExoPlayer, Media3, * Room, WorkManager or Chaquopy, none of which can execute on the JVM. They were overridden * eight times in a row with `COVERAGE_EXCEPTION`, and the last few were perverse: extracting * decisions *out* of a service into tested classes lowered the number, and writing an * instrumented test for a migration lowered it again. A metric that punishes both is measuring * how much Android glue exists rather than how well the logic is tested. * * **Adding to this list needs a reason, not just a name.** The bar is "cannot execute off a * device", not "awkward to test" -- QueueRepository, PlaybackAdvancer, EpisodeDownloader and * ChapterFetcher all touch framework-adjacent things and all stay measured, because their * logic runs fine against fakes. */ val coverageExclusions = listOf( // Compose screens, one file at a time. See the note above: `**/ui/**` also hid the view // models, which are ordinary classes and are measured. // // Every Compose file compiles a `ComposableSingletons` holder for its constant lambdas. // It is generated, there is nothing in it to test, and it belongs to the screen it is // named after. "**/ui/ComposableSingletons\$*", "**/ui/MainActivity*", "**/ui/ShareReceiverActivity*", "**/ui/BreakJudgingKt*", "**/ui/DripSettingsKt*", "**/ui/EpisodeDescriptionKt*", "**/ui/EpisodeOverflowKt*", "**/ui/EpisodeRowKt*", "**/ui/EpisodeScreenKt*", "**/ui/EpisodeTimelineKt*", "**/ui/EpisodeTransportKt*", "**/ui/FurnitureSkipSettingsStateKt*", "**/ui/LibraryScreenKt*", "**/ui/NowPlayingBarKt*", "**/ui/PlayerBarKt*", "**/ui/PlayerMessageKt*", "**/ui/QueueScreenKt*", "**/ui/RuleConditionFieldsKt*", "**/ui/RuleEditScreenKt*", "**/ui/RulesScreenKt*", "**/ui/SettingRowsKt*", "**/ui/SettingsScreenKt*", "**/ui/ShowSettingsSheetKt*", "**/ui/SkipServerSettingsStateKt*", "**/ui/SkipSettingsKt*", "**/ui/SubscriptionRowKt*", "**/ui/SubscriptionScreenKt*", "**/ui/CategorySettingKt*", "**/ui/TranscriptScreenKt*", // A screen's own parameter and state objects: data classes of callbacks and already-decided // values, handed from one composable to another. They decide nothing, they are constructed // only inside the files above, and the only test that could reach one would call its // constructor and assert nothing. // // Excluded by what they are rather than by which file they sit in. Most are declared inside // their screen and were already covered by the globs above; these five happen to have files // of their own, and a line that excluded one and measured the other would be describing the // author's filing rather than the code. "**/ui/EpisodeActions*", "**/ui/EpisodeDetailActions*", "**/ui/EpisodeDetailState*", "**/ui/EpisodeHooks*", "**/ui/EpisodePlayback*", "**/ui/FurnitureSkipSettings*", "**/ui/LibraryContent*", "**/ui/LibraryNav*", "**/ui/LibraryTopBarActions*", "**/ui/NowPlayingActions*", "**/ui/OverflowContent*", "**/ui/QueueNav*", "**/ui/QueueRowActions*", "**/ui/ScreenSource*", "**/ui/SkipServerSettings*", "**/ui/SkipSettings*", "**/ui/SubscriptionNav*", "**/ui/SubscriptionTopBarActions*", "**/R.class", "**/R\$*.class", "**/BuildConfig.*", "**/*_Impl*", // ExoPlayer, MediaSession and the browse tree: no JVM equivalent to run against. "**/playback/PlaybackService*", "**/playback/PlayerConnection*", "**/playback/ListenSessionCallback*", "**/playback/LibraryTree*", "**/playback/NotificationControls*", "**/playback/PodcastPlayer*", // Four one-line getters over ExoPlayer state. It lives inside PlaybackService.kt and so // reads as covered by the line above, but it compiles to its own class and was being // measured. It is the seam that makes PositionWriter testable, not logic in its own right. "**/playback/ExoPlayhead*", // Room's own machinery, including the migrations -- proven on a device by MigrationTest. "**/data/AppDatabase*", // WorkManager entry points; the decisions live in DownloadDrainer, RetentionSweeper and // QueuedDownloads, which are measured. "**/work/RefreshWorker*", "**/work/DownloadWorker*", "**/work/RetentionWorker*", // Same rule: the decisions are WaitingDownloads', which bounds the retry two ways and is // measured. This is the WorkManager entry point that calls it. "**/work/RetryWaitingWorker*", "**/work/Scheduling*", // DataStore: a Context, a file and a generated Flow. Nothing here decides anything. "**/data/Preferences*", // The embedded CPython bridge, and the app/graph wiring that only constructs things. "**/download/ChaquopyAudioSource*", "**/youtube/YouTubeAudio*", "**/data/Graph*", "**/data/AppGraph*", "**/ListenApp*", // On-device detection used three entries here -- MediaCodecClipSource for the decode loop and // the two workers that drove it. All three classes are gone; see the on-device-detection tag. ) tasks.register("jacocoTestReport") { dependsOn("testDebugUnitTest") reports { xml.required.set(true) html.required.set(true) } val kotlinDebugTree = fileTree("${layout.buildDirectory.get()}/tmp/kotlin-classes/debug") { exclude(coverageExclusions) } val javaDebugTree = fileTree("${layout.buildDirectory.get()}/intermediates/javac/debug") { exclude(coverageExclusions) } classDirectories.setFrom(files(kotlinDebugTree, javaDebugTree)) sourceDirectories.setFrom(files("src/main/java")) executionData.setFrom(fileTree(layout.buildDirectory) { include("**/*.exec") }) }