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" 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") } } buildTypes { debug { enableUnitTestCoverage = true } release { isMinifyEnabled = false } } buildFeatures { compose = 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") }) }