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") } 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 } 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") implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.3") 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" } tasks.register("jacocoTestReport") { dependsOn("testDebugUnitTest") reports { xml.required.set(true) html.required.set(true) } // ui/** is excluded for the same reason as FeeDreader: Compose screens are impractical to // unit-test for coverage and including them makes the metric meaningless. val kotlinDebugTree = fileTree("${layout.buildDirectory.get()}/tmp/kotlin-classes/debug") { exclude("**/ui/**", "**/R.class", "**/R$*.class", "**/BuildConfig.*", "**/*_Impl*") } val javaDebugTree = fileTree("${layout.buildDirectory.get()}/intermediates/javac/debug") { exclude("**/ui/**", "**/R.class", "**/R$*.class", "**/BuildConfig.*", "**/*_Impl*") } classDirectories.setFrom(files(kotlinDebugTree, javaDebugTree)) sourceDirectories.setFrom(files("src/main/java")) executionData.setFrom(fileTree(layout.buildDirectory) { include("**/*.exec") }) }