The audit ofb0a5e7213found 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 tob0a5e7213: 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). At737eb50the 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.
276 lines
11 KiB
Kotlin
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) }
|