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
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
|
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/TierSettingKt*",
"**/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<JacocoReport>("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") })
}
|