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. abiFilters += 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. * * 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( "**/ui/**", "**/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*", // 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*", "**/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*", ) 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") }) }