1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
|
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<JacocoReport>("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") })
}
|