straw/strawApp/build.gradle.kts
Cobb c62f195489 test: run the service's autoplay decisions in JVM tests, not text matches
The audit of b0a5e7213 found the playback service's autoplay wiring guarded
by matches on its source text. A comment, a dead branch or "|| true" passed
them and a harmless rewrite failed them, and nothing tested how the service
builds and drives its handoff: the 30 second bound, the main-thread
hand-over, the end of a video starting an attempt, the staleness fence, the
allowlist gate.

What the service decides about autoplay moves, unchanged in behaviour, to
ServiceAutoplay.kt: how it builds its AutoplayHandoff (hand-overs on
Dispatchers.Main, the 30 second bound, a release that asks Media3 again with
false), which player state starts an attempt, the allowlist gate on the
next video, and the fence on the hand-over with the check that the player
took the video. ServiceAutoplayTest runs them with Dispatchers.Main replaced
by a test dispatcher on virtual time (kotlinx-coroutines-test, a new
test-only dependency).

What stays in PlaybackService is its connection to Media3: the
onUpdateNotification override, building the handoff, attaching the
end-of-video listener, tryAutoplay, and the cancel in onDestroy.
AutoplayWiringTest now reads those from the compiled class instead of the
source text (CompiledReach: what a method calls, following the class's own
helpers, lambdas and nested classes). It sees calls, not values.

AutoplayHandoffTest now dispatches the way the main thread does (a launched
attempt runs later, never inside start) on virtual time, and adds: the
release asks Media3 after the hold is dropped; a failed lookup or hand-over
does not reach the scope's exception handler; an attempt gives up at its
bound and not a millisecond before. One test keeps Dispatchers.Unconfined on
purpose, for a dispatcher that resumes the replaced attempt inside
job.cancel().

Correction to b0a5e7213: its message says "The service code cannot run in a
JVM test, so this checks the source". The service class cannot run there,
but the decisions in it can once they are taken out of it, which is what
this commit does; and the source check that commit added was passed by a
comment or a dead branch, as the audit measured.

Measured in throwaway build containers (mutants one at a time, the test JVM
under ulimit -v 8388608, MALLOC_ARENA_MAX=2, timeout 900). At 737eb50 the
shipped suite (46 tests) let the audit's mutants H06, H11, H15, H28, S03,
S04, S05, S08, S09, S10, S11, S12, S13 and S14 survive and failed on its
harmless rewrites F01, F02 and F03. On this tree: 62 of 62 JVM tests pass;
the service mutants passing Media3's answer straight through, building the
release with no session or a no-op update, commenting out or removing the
onDestroy cancel, and wrapping it in "if (false)" all fail
AutoplayWiringTest; commenting out the update argument does not compile.
The full mutant run is at the head of this round.
2026-10-22 18:45:47 -07:00

276 lines
11 KiB
Kotlin

/*
* SPDX-FileCopyrightText: 2026 Sulkta
* SPDX-License-Identifier: GPL-3.0-or-later
*
* :strawApp — thin Android application shell. Day-2: pulls NewPipeExtractor,
* Media3, Ktor-style HTTP-via-OkHttp + kotlinx-serialization JSON for the
* search → detail → player → SponsorBlock + RYD flow. We keep our deps in
* this module, NOT in the shared libs.versions.toml, so upstream NewPipe
* stays cleanly mergeable.
*/
import com.android.build.api.dsl.ApplicationExtension
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.jetbrains.kotlin.compose)
alias(libs.plugins.jetbrains.kotlinx.serialization)
}
kotlin {
jvmToolchain(21)
}
configure<ApplicationExtension> {
compileSdk {
version = release(STRAW_SDK_COMPILE_MAJOR) {
minorApiLevel = STRAW_SDK_COMPILE_MINOR
}
}
namespace = STRAW_APPLICATION_ID
defaultConfig {
applicationId = STRAW_APPLICATION_ID
minSdk { version = release(24) }
targetSdk { version = release(STRAW_SDK_TARGET) }
versionCode = STRAW_VERSION_CODE
versionName = STRAW_VERSION_NAME
resValue("string", "app_name", "Straw")
}
// Explicit signing so CI / release builds reuse ONE keystore instead of
// Gradle's per-machine auto-generated ~/.android/debug.keystore — a fresh
// CI container would otherwise mint a DIFFERENT key and break in-place
// updates for everyone on fdroid. Driven by env so local dev needs no
// setup (unset → Gradle's default debug signing):
// STRAW_KEYSTORE_FILE — path to the keystore
// STRAW_KEYSTORE_PASS / STRAW_KEY_ALIAS / STRAW_KEY_PASS — creds
// The signing keystore is supplied out-of-band; debug builds use androiddebugkey.
val strawKeystoreFile: String? = System.getenv("STRAW_KEYSTORE_FILE")
signingConfigs {
if (strawKeystoreFile != null) {
create("sulkta") {
storeFile = file(strawKeystoreFile)
storePassword = System.getenv("STRAW_KEYSTORE_PASS") ?: "android"
keyAlias = System.getenv("STRAW_KEY_ALIAS") ?: "androiddebugkey"
keyPassword = System.getenv("STRAW_KEY_PASS") ?: "android"
}
}
}
buildTypes {
// R8 enabled on BOTH variants — we publish the debug APK to
// fdroid (com.sulkta.straw.debug) per the existing pipeline,
// and audit-flagged Log.d strips depended on R8 actually
// running on the variant we ship. Keep rules in
// strawApp/proguard-rules.pro cover UniFFI + JNA +
// kotlinx-serialization companions.
debug {
// NOT debuggable, even though this is the `debug` buildType: the
// debug variant is what we PUBLISH to fdroid.sulkta.com, and a
// shipped debuggable APK lets any ADB/USB attacker `run-as` the
// package and dump watch/search history + the full subscription
// list — defeating allowBackup=false / dataExtractionRules on a
// privacy-focused client. Flipping this needs no package-id
// cutover: signing + the `.debug` suffix below are unchanged, so
// in-place fdroid updates keep working.
isDebuggable = false
// Keep the `.debug` applicationId suffix (package stays
// com.sulkta.straw.debug) so in-place fdroid updates + the in-app
// auto-updater keep working and nobody loses their subs/history.
// But present as just "Straw" in the launcher — we're past the
// debug-phase branding. Dropping the suffix entirely is a
// separate, deliberate release-track cutover (it'd change the
// package id → force a reinstall + wipe app data).
applicationIdSuffix = ".debug"
resValue("string", "app_name", "Straw")
isMinifyEnabled = true
isShrinkResources = true
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro",
)
// Reuse the canonical fdroid keystore in CI/release; fall back to
// Gradle's default debug keystore for local dev (env unset).
if (strawKeystoreFile != null) {
signingConfig = signingConfigs.getByName("sulkta")
}
}
release {
isMinifyEnabled = true
isShrinkResources = true
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro",
)
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_21
targetCompatibility = JavaVersion.VERSION_21
}
buildFeatures {
compose = true
buildConfig = true
resValues = true
}
packaging {
resources {
excludes += setOf(
"META-INF/README.md",
"META-INF/CHANGES",
"META-INF/COPYRIGHT",
"META-INF/INDEX.LIST",
"META-INF/io.netty.versions.properties",
)
}
}
}
dependencies {
// Compose + AndroidX core
implementation(libs.androidx.activity)
implementation(libs.androidx.core)
implementation(libs.jetbrains.compose.runtime)
implementation(libs.jetbrains.compose.foundation)
implementation(libs.jetbrains.compose.material3)
implementation(libs.jetbrains.compose.ui)
implementation("androidx.compose.material:material-icons-extended:1.7.8")
// Lifecycle + ViewModel for Compose
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.10.0")
implementation("androidx.lifecycle:lifecycle-runtime-compose:2.10.0")
// Coroutines
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.11.0")
// Image loading
implementation(libs.coil.compose)
implementation(libs.coil.network.okhttp)
// NewPipeExtractor (JVM/Android-only) + its OkHttp dep
// libs.newpipe.extractor — REMOVED in Path C-6. Extractor is now strawcore
// (Rust + rustypipe via UniFFI). See rust/strawcore/ + the cargoBuild +
// uniffiBindgen Gradle tasks below.
implementation(libs.squareup.okhttp)
// JSON for SponsorBlock + Return YouTube Dislike clients
implementation(libs.kotlinx.serialization.json)
// Media3 ExoPlayer
implementation("androidx.media3:media3-exoplayer:1.4.1")
implementation("androidx.media3:media3-exoplayer-dash:1.4.1")
implementation("androidx.media3:media3-exoplayer-hls:1.4.1")
implementation("androidx.media3:media3-ui:1.4.1")
// Media3 session — MediaSessionService for background audio + lock-screen controls.
implementation("androidx.media3:media3-session:1.4.1")
// Guava ListenableFuture support for awaiting MediaController connect.
implementation("androidx.concurrent:concurrent-futures-ktx:1.2.0")
// WorkManager — periodic background poll of fdroid.sulkta.com index
// for self-update notifications. CoroutineWorker is built into the
// base work-runtime artifact as of 2.10.
implementation(libs.androidx.work.runtime)
// strawcore — Rust YouTube extractor via UniFFI/JNA. Built by the
// cargoBuild + uniffiBindgen tasks below; phase U-2+ exposes search /
// streamInfo / channelInfo to replace NewPipeExtractor.
implementation("net.java.dev.jna:jna:5.14.0@aar")
// JVM unit tests under src/test, run by `./gradlew :strawApp:testDebugUnitTest`.
// Before this line that source set had no test framework, so it was
// never compiled or run (the note at the top of LogDumpScrubTest).
testImplementation(libs.junit)
// Virtual time and a stand-in for Dispatchers.Main in the autoplay tests.
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.11.0")
}
// =============================================================================
// Phase U-1 / Path-C-2 — Rust core build glue.
//
// Two tasks chain into the Android build:
// cargoBuild — cross-compiles rust/strawcore for the four Android ABIs
// via cargo-ndk and drops the .so files in strawApp/src/main/jniLibs/.
// uniffiBindgen — generates the Kotlin bindings from the freshly-built lib
// into strawApp/src/main/java/uniffi/strawcore/.
//
// Both depend on:
// - cargo + rustup with the four Android targets installed
// - cargo-ndk on PATH
// - ANDROID_NDK_HOME pointing at an NDK with the right toolchains
// All of that lives in the Sulkta build container.
// =============================================================================
val rustRoot = file("../rust").absolutePath
val jniLibsDir = file("src/main/jniLibs").absolutePath
val bindingsDir = file("src/main/java").absolutePath
val cargoHome: String = System.getenv("CARGO_HOME") ?: "/caches/cargo"
val cargoBin: String = "$cargoHome/bin/cargo"
val ndkHome: String = System.getenv("ANDROID_NDK_HOME")
?: System.getenv("ANDROID_NDK_ROOT")
?: "/caches/android-sdk/ndk/27.2.12479018"
// Honor CARGO_TARGET_DIR if set (our build container redirects it to a
// cache mount because the container's writable rootfs hits 100% before
// the cross-compile for 4 ABIs finishes). Falls back to the default
// `<workspace>/target`.
val cargoTargetDir: String = System.getenv("CARGO_TARGET_DIR")
?: "$rustRoot/target"
val cargoBuild by tasks.registering(Exec::class) {
group = "rust"
description = "Cross-compile strawcore for all Android ABIs via cargo-ndk."
workingDir = file(rustRoot)
environment("ANDROID_NDK_HOME", ndkHome)
environment("PATH", "$cargoHome/bin:${System.getenv("PATH") ?: ""}")
commandLine = listOf(
cargoBin, "ndk",
"-t", "arm64-v8a",
"-t", "armeabi-v7a",
"-t", "x86",
"-t", "x86_64",
"-o", jniLibsDir,
"build", "--release", "-p", "strawcore",
)
standardOutput = System.out
errorOutput = System.err
}
val cargoBuildHost by tasks.registering(Exec::class) {
group = "rust"
description = "Build host-arch debug strawcore so bindgen can read its UniFFI metadata."
workingDir = file(rustRoot)
environment("PATH", "$cargoHome/bin:${System.getenv("PATH") ?: ""}")
commandLine = listOf(cargoBin, "build", "-p", "strawcore")
standardOutput = System.out
errorOutput = System.err
}
val uniffiBindgen by tasks.registering(Exec::class) {
group = "rust"
description = "Generate Kotlin bindings for strawcore via uniffi-bindgen."
dependsOn(cargoBuildHost)
workingDir = file(rustRoot)
environment("PATH", "$cargoHome/bin:${System.getenv("PATH") ?: ""}")
commandLine = listOf(
cargoBin, "run", "--quiet", "--bin", "uniffi-bindgen", "--",
"generate",
"--library", "$cargoTargetDir/debug/libstrawcore.so",
"--crate", "strawcore",
"--language", "kotlin",
"--no-format",
"--out-dir", bindingsDir,
)
standardOutput = System.out
errorOutput = System.err
}
// Make sure Android's JNI-libs merge picks up the freshly built .so files,
// and Kotlin compilation can resolve the generated bindings.
tasks.matching { it.name.startsWith("merge") && it.name.endsWith("JniLibFolders") }
.configureEach { dependsOn(cargoBuild) }
tasks.matching { it.name.startsWith("compile") && it.name.endsWith("Kotlin") }
.configureEach { dependsOn(uniffiBindgen) }