feat(android): conformance on a device, and a start position for ExoPlayer
DR-247. The desktop suite cannot reach ExoPlayer: it needs an Android Context and a Looper, so it exists only inside an app process. These are the same behaviours, asserted against the engine itself. Writing them forced the same gap open that mpv had. JellyTauPlayer.load(url, mediaId) had no way to express a start position, so every caller loaded and then seeked — the test could not even be written against the old signature, which is a stronger statement than a failing assertion. The position now goes to ExoPlayer with the media item via setMediaItem(item, startPositionMs), and the two-argument form delegates to it, so nothing else had to change. Running one suite against both engines settled something guesswork could not: seekWhileOpeningIsHonoured passes on ExoPlayer with no fix ExoPlayer already queues a seek issued before prepare() completes. So the lost-seek half of DR-241 was mpv-specific, and only the missing vocabulary for a start position was shared. That is the difference between "both engines have this bug" and knowing which one does. All seven cases pass on device (ROD2-W09, arm64). The fixture is a silent WAV synthesised in the cache directory at setup rather than committed or pushed: no binary in the repo, no adb step, and an exact duration, which the seek assertions depend on. Also adds the instrumentation runner to defaultConfig and teaches sync-android-sources.sh to mirror src/androidTest, the way it already mirrors src/test — so the canonical tree stays the only place tests are edited. Run: ./gradlew :app:connectedUniversalDebugAndroidTest -x :app:rustBuildUniversalDebug
This commit is contained in:
@@ -441,6 +441,7 @@ Internal architecture, components, and application logic.
|
||||
| DR-243 | Every engine passes one conformance suite, and a `FakePlayer` implements the contract deterministically. The suite is written before the second engine so it cannot encode whatever the first happened to do, and it drives readiness through a harness rather than sleeping. `FakePlayer` models the one behaviour that matters — opening is not instantaneous — so the load/seek race can be expressed on purpose, and lets the controller, queue, autoplay and session logic be tested with no engine at all | Player | UR-081 | In Progress |
|
||||
| DR-244 | `MpvPlayer` implements `MediaPlayer` over libmpv, applying the start position at load time via mpv's own `start` option rather than seeking after an asynchronous `loadfile`, and holding a seek that arrives during `Opening` until the file loads. A standalone `player-conformance` binary runs the suite against it with audio and video routed to null, so a wrapper is verifiable without building or launching the app | Player | UR-081, UR-040 | Done |
|
||||
| DR-245 | `LegacyPlayer` drives the old `PlayerBackend` through the `MediaPlayer` contract, so engines not yet ported keep working during the migration and the two designs can be compared on one engine and one file. It reproduces the old load-then-play-then-seek sequence faithfully rather than a fixed-up version, because making it pass would defeat its purpose | Player | UR-081 | In Progress |
|
||||
| DR-247 | ExoPlayer can be told where to start. `JellyTauPlayer.load(url, mediaId)` had no way to express a start position, so every caller loaded and then seeked; the position is now handed to ExoPlayer with the media item via `setMediaItem(item, startPositionMs)`, and the two-argument form delegates to it. Running the conformance cases on a device also settled which half of DR-241 was engine-specific: ExoPlayer already queues a seek issued before `prepare()` completes, so it never had the lost-seek defect mpv did — only the missing vocabulary for a start position | Player | UR-081, UR-005 | Done |
|
||||
| DR-198 | The webview runs under a real Content-Security-Policy, and the asset protocol is scoped to the one directory it still serves. `csp` was `null`, which disables CSP entirely: any script that reached the web layer — through a future `{@html}`, a dependency, or a devtools paste — would have inherited the whole IPC surface, and with it the user's session. `script-src 'self'` (Tauri injects a nonce for SvelteKit's inline bootstrap script at build time, so no `'unsafe-inline'` is needed) plus `object-src`/`frame-src 'none'` and `base-uri 'self'` is the part that is genuinely restrictive. `img-src`/`media-src`/`connect-src` cannot be: the Jellyfin origin is typed in by the user at run time and is commonly plain `http` on a LAN, so they allow `http:`/`https:` — a wide grant for *data*, but one that still bars `file:`, `filesystem:` and scripting schemes, and leaves `script-src` untouched. `style-src` keeps `'unsafe-inline'` because Svelte compiles `style="…"` attributes (including `app.html`'s `display: contents` wrapper) into markup; this is safe only while no `<style>` element survives into `index.html`, since a nonce there would make Tauri's injection outrank — and therefore void — `'unsafe-inline'`. `worker-src blob:` and `media-src blob:` are hls.js: it demuxes in a worker built from a blob and attaches MSE through `URL.createObjectURL`. `asset:` and `http://asset.localhost` are the same protocol under the two naming schemes `convertFileSrc` emits (custom scheme on Linux/macOS, `http` host on Windows/Android); `ipc:`/`http://ipc.localhost` is the invoke transport, which would otherwise be blocked by `connect-src`. A run-time CSP naming the server origin exactly was rejected: Tauri computes the header from immutable config when it serves the HTML, so it would mean rebuilding config and reloading the webview on every server change, for a policy the user can already point anywhere. The asset-protocol scope narrows from `$APPDATA/**` to `$APPDATA/thumbnails/**` — since DR-137 moved downloaded media to the loopback server, `imageCache` is the only `convertFileSrc` caller left, so the database and the encrypted-token fallback file no longer sit inside the grant | Security | UR-012, UR-071 | Done |
|
||||
|
||||
---
|
||||
@@ -773,6 +774,7 @@ Internal architecture, components, and application logic.
|
||||
| IT-013 | Background-audio handoff on Android: background/lock continues audio via native service and stops video decode; foreground resumes video at position | IR-025, UR-040 | Pending |
|
||||
| IT-016 | Offline library listing end-to-end: with the server unreachable, a library page lists only downloaded media with the toggle off, and additionally reveals greyed-out cached catalog entries with the toggle on | UR-052, DR-078, DR-079, DR-080 | Done |
|
||||
| IT-017 | A download queued from a greyed-out offline catalog entry persists and is resolved and started on reconnect | UR-052, UR-011 | Done |
|
||||
| IT-018 | The conformance cases run against ExoPlayer on a device: opening from the beginning and at a position, a seek issued while still preparing, a seek after open, pause and play observable, stop silent and idempotent, and a load cancelled by stop never playing. The fixture is a silent WAV synthesised at setup, so the repo carries no media and the duration is exact | DR-247 | Done |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -36,6 +36,19 @@ if [ -d "$TEST_SOURCE_DIR" ]; then
|
||||
echo " Copied unit tests: src/test"
|
||||
fi
|
||||
|
||||
# Instrumented tests (src/androidTest). These need a device: they drive
|
||||
# ExoPlayer, which requires an Android Context and a Looper and therefore
|
||||
# cannot run from the desktop conformance suite. Run with
|
||||
# `./gradlew :app:connectedDebugAndroidTest` from gen/android.
|
||||
ANDROID_TEST_SOURCE_DIR="$PROJECT_ROOT/src-tauri/android/src/androidTest/java/com/dtourolle/jellytau"
|
||||
ANDROID_TEST_TARGET_DIR="$PROJECT_ROOT/src-tauri/gen/android/app/src/androidTest/java/com/dtourolle/jellytau"
|
||||
if [ -d "$ANDROID_TEST_SOURCE_DIR" ]; then
|
||||
rm -rf "$ANDROID_TEST_TARGET_DIR"
|
||||
mkdir -p "$ANDROID_TEST_TARGET_DIR"
|
||||
cp -r "$ANDROID_TEST_SOURCE_DIR"/. "$ANDROID_TEST_TARGET_DIR/"
|
||||
echo " Copied instrumented tests: src/androidTest"
|
||||
fi
|
||||
|
||||
# Copy individual Kotlin files (like VideoOverlayManager.kt)
|
||||
for kt_file in "$SOURCE_DIR"/*.kt; do
|
||||
if [ -f "$kt_file" ]; then
|
||||
|
||||
@@ -46,6 +46,9 @@ android {
|
||||
targetSdk = 36
|
||||
versionCode = tauriProperties.getProperty("tauri.android.versionCode", "1").toInt()
|
||||
versionName = tauriProperties.getProperty("tauri.android.versionName", "1.0")
|
||||
// Required to run the on-device conformance suite
|
||||
// (src/androidTest). See docs/specs/media-player-controller.md.
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
}
|
||||
signingConfigs {
|
||||
create("release") {
|
||||
@@ -147,6 +150,7 @@ dependencies {
|
||||
testImplementation("junit:junit:4.13.2")
|
||||
androidTestImplementation("androidx.test.ext:junit:1.1.4")
|
||||
androidTestImplementation("androidx.test.espresso:espresso-core:3.5.0")
|
||||
androidTestImplementation("androidx.test:runner:1.5.2")
|
||||
}
|
||||
|
||||
apply(from = "tauri.build.gradle.kts")
|
||||
+252
@@ -0,0 +1,252 @@
|
||||
package com.dtourolle.jellytau.player
|
||||
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Before
|
||||
import org.junit.After
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import java.io.File
|
||||
import kotlin.math.abs
|
||||
|
||||
/**
|
||||
* The MediaPlayer conformance cases, run against ExoPlayer on a real device.
|
||||
*
|
||||
* The desktop suite (src-tauri/src/player/conformance.rs) cannot reach here:
|
||||
* ExoPlayer needs an Android Context and a Looper, so it only exists inside an
|
||||
* app process. These are the same behaviours, asserted against the engine
|
||||
* itself rather than the Rust wrapper — the layer below the contract.
|
||||
*
|
||||
* The fixture is generated rather than committed: a long silent WAV written to
|
||||
* the cache directory at setup. No binary in the repo, no `adb push` step, and
|
||||
* the duration is exact, which matters for the seek assertions.
|
||||
*
|
||||
* Run: ./gradlew :app:connectedDebugAndroidTest (from src-tauri/gen/android)
|
||||
*
|
||||
* TRACES: UR-081 | DR-247
|
||||
*/
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class PlayerConformanceTest {
|
||||
|
||||
private lateinit var player: JellyTauPlayer
|
||||
private lateinit var mediaUrl: String
|
||||
|
||||
/** Long enough to seek well past any buffer. */
|
||||
private val fixtureSeconds = 1200
|
||||
|
||||
/**
|
||||
* ExoPlayer lands on the nearest sync sample, and a `prepare` is not
|
||||
* instantaneous. Generous on purpose: a tight bound here produces a test
|
||||
* that fails on a slow device and teaches people to re-run until green.
|
||||
*/
|
||||
private val toleranceSeconds = 10.0
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
val context = InstrumentationRegistry.getInstrumentation().targetContext
|
||||
JellyTauPlayer.initialize(context)
|
||||
player = JellyTauPlayer.getInstance()
|
||||
|
||||
val fixture = File(context.cacheDir, "conformance-$fixtureSeconds.wav")
|
||||
if (!fixture.exists() || fixture.length() < 1024) {
|
||||
writeSilentWav(fixture, fixtureSeconds)
|
||||
}
|
||||
mediaUrl = fixture.toURI().toString()
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
onMain { player.stop() }
|
||||
// Leave nothing playing for the next case.
|
||||
Thread.sleep(200)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- cases
|
||||
|
||||
@Test
|
||||
fun opensFromTheBeginning() {
|
||||
onMain { player.load(mediaUrl, "conformance") }
|
||||
awaitLoaded()
|
||||
|
||||
assertNear(0.0, position(), "playback should start at the beginning")
|
||||
assertTrue("duration should be known once loaded", duration() > 0)
|
||||
}
|
||||
|
||||
/**
|
||||
* DR-241. Opening at a position starts *there*, not at zero.
|
||||
*
|
||||
* `load(url, mediaId)` has no way to express a start position, so every
|
||||
* caller loads and then seeks — and a seek issued against a player that is
|
||||
* still preparing is the window resume was lost in on the desktop side.
|
||||
* This is the same defect on ExoPlayer.
|
||||
*/
|
||||
@Test
|
||||
fun opensAtAStartPosition() {
|
||||
val start = 600.0
|
||||
onMain { player.load(mediaUrl, "conformance", start) }
|
||||
awaitLoaded()
|
||||
|
||||
assertTrue(
|
||||
"opened at ${start}s but playback began at ${position()}s - " +
|
||||
"the start position was dropped",
|
||||
position() > 1.0
|
||||
)
|
||||
assertNear(start, position(), "start position")
|
||||
}
|
||||
|
||||
/** DR-241. A seek issued while still preparing is honoured, not lost. */
|
||||
@Test
|
||||
fun seekWhileOpeningIsHonoured() {
|
||||
val target = 300.0
|
||||
onMain {
|
||||
player.load(mediaUrl, "conformance")
|
||||
// Deliberately before the player is ready: this is the race,
|
||||
// expressed on purpose rather than stumbled into.
|
||||
player.seek(target)
|
||||
}
|
||||
awaitLoaded()
|
||||
|
||||
assertNear(target, position(), "seek issued while opening")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun seeksAfterOpen() {
|
||||
onMain { player.load(mediaUrl, "conformance") }
|
||||
awaitLoaded()
|
||||
|
||||
val target = 420.0
|
||||
onMain { player.seek(target) }
|
||||
awaitPosition(target)
|
||||
|
||||
assertNear(target, position(), "seek after open")
|
||||
}
|
||||
|
||||
/** DR-239. Pause and play are observable, not merely accepted. */
|
||||
@Test
|
||||
fun pauseAndPlayAreObservable() {
|
||||
onMain { player.load(mediaUrl, "conformance") }
|
||||
awaitLoaded()
|
||||
|
||||
onMain { player.pause() }
|
||||
awaitPlaying(false)
|
||||
assertFalse("a paused player must not report playing", isPlaying())
|
||||
|
||||
onMain { player.play() }
|
||||
awaitPlaying(true)
|
||||
assertTrue("a resumed player must report playing", isPlaying())
|
||||
}
|
||||
|
||||
/** `stop()` releases the item, is silent, and can be called twice. */
|
||||
@Test
|
||||
fun closeIsSilentAndIdempotent() {
|
||||
onMain { player.load(mediaUrl, "conformance") }
|
||||
awaitLoaded()
|
||||
|
||||
onMain { player.stop() }
|
||||
awaitPlaying(false)
|
||||
assertFalse("a stopped player must not report playing", isPlaying())
|
||||
|
||||
onMain { player.stop() }
|
||||
assertFalse("stop must be idempotent", isPlaying())
|
||||
}
|
||||
|
||||
/**
|
||||
* An open cancelled by stop must not come back to life.
|
||||
*
|
||||
* The shape of "audio kept playing after leaving the player": a prepare
|
||||
* still in flight completed after the stop, with nothing left to tell it
|
||||
* not to.
|
||||
*/
|
||||
@Test
|
||||
fun closeDuringOpenNeverPlays() {
|
||||
onMain {
|
||||
player.load(mediaUrl, "conformance")
|
||||
player.stop()
|
||||
}
|
||||
Thread.sleep(2000)
|
||||
|
||||
assertFalse(
|
||||
"a load cancelled by stop must not start playing",
|
||||
isPlaying()
|
||||
)
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------- helpers
|
||||
|
||||
private fun onMain(block: () -> Unit) {
|
||||
InstrumentationRegistry.getInstrumentation().runOnMainSync(block)
|
||||
}
|
||||
|
||||
private fun position(): Double = readOnMain { player.getPosition() }
|
||||
private fun duration(): Double = readOnMain { player.getDuration() }
|
||||
private fun isPlaying(): Boolean = readOnMain { player.getExoPlayer().isPlaying }
|
||||
|
||||
private fun <T> readOnMain(block: () -> T): T {
|
||||
var out: T? = null
|
||||
InstrumentationRegistry.getInstrumentation().runOnMainSync { out = block() }
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
return out as T
|
||||
}
|
||||
|
||||
/** Poll a state the player publishes rather than sleeping a fixed time. */
|
||||
private fun await(what: String, timeoutMs: Long = 15_000, predicate: () -> Boolean) {
|
||||
val deadline = System.currentTimeMillis() + timeoutMs
|
||||
while (System.currentTimeMillis() < deadline) {
|
||||
if (predicate()) return
|
||||
Thread.sleep(50)
|
||||
}
|
||||
throw AssertionError("timed out waiting for $what")
|
||||
}
|
||||
|
||||
private fun awaitLoaded() {
|
||||
await("the player to report a duration") { duration() > 0 }
|
||||
// One more beat so a start position or a deferred seek has landed.
|
||||
Thread.sleep(500)
|
||||
}
|
||||
|
||||
private fun awaitPosition(target: Double) =
|
||||
await("position to reach ${target}s") { abs(position() - target) <= toleranceSeconds }
|
||||
|
||||
private fun awaitPlaying(expected: Boolean) =
|
||||
await("isPlaying == $expected", 5_000) { isPlaying() == expected }
|
||||
|
||||
private fun assertNear(expected: Double, actual: Double, what: String) {
|
||||
assertTrue(
|
||||
"$what: expected ~${expected}s, got ${actual}s (tolerance ${toleranceSeconds}s)",
|
||||
abs(actual - expected) <= toleranceSeconds
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Write a silent 8 kHz mono 16-bit WAV of `seconds` length.
|
||||
*
|
||||
* Synthesised rather than committed so the repo carries no media, and so
|
||||
* the duration is exact — the seek assertions depend on it.
|
||||
*/
|
||||
private fun writeSilentWav(file: File, seconds: Int) {
|
||||
val sampleRate = 8000
|
||||
val dataBytes = sampleRate * 2 * seconds
|
||||
file.outputStream().buffered().use { out ->
|
||||
fun le32(v: Int) = out.write(
|
||||
byteArrayOf(
|
||||
(v and 0xff).toByte(),
|
||||
((v shr 8) and 0xff).toByte(),
|
||||
((v shr 16) and 0xff).toByte(),
|
||||
((v shr 24) and 0xff).toByte()
|
||||
)
|
||||
)
|
||||
fun le16(v: Int) =
|
||||
out.write(byteArrayOf((v and 0xff).toByte(), ((v shr 8) and 0xff).toByte()))
|
||||
|
||||
out.write("RIFF".toByteArray()); le32(36 + dataBytes); out.write("WAVE".toByteArray())
|
||||
out.write("fmt ".toByteArray()); le32(16); le16(1); le16(1)
|
||||
le32(sampleRate); le32(sampleRate * 2); le16(2); le16(16)
|
||||
out.write("data".toByteArray()); le32(dataBytes)
|
||||
|
||||
val chunk = ByteArray(sampleRate * 2) // one second of silence
|
||||
repeat(seconds) { out.write(chunk) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -556,11 +556,31 @@ class JellyTauPlayer(private val appContext: Context) {
|
||||
* @param mediaId The unique ID for this media item
|
||||
*/
|
||||
fun load(url: String, mediaId: String) {
|
||||
load(url, mediaId, 0.0)
|
||||
}
|
||||
|
||||
/**
|
||||
* Load [url] and begin at [startPositionSeconds].
|
||||
*
|
||||
* The start position is handed to ExoPlayer with the media item, not seeked
|
||||
* to afterwards. `prepare()` is asynchronous, so a seek issued straight
|
||||
* after a load targets a player that is still preparing: ExoPlayer clamps it
|
||||
* back to zero and the item plays from the beginning. That is what made
|
||||
* resume and transcoded skip start over, and it is why callers must never
|
||||
* express a start position as load-then-seek.
|
||||
*
|
||||
* TRACES: UR-081, UR-005 | DR-241, DR-247
|
||||
*/
|
||||
fun load(url: String, mediaId: String, startPositionSeconds: Double) {
|
||||
mainHandler.post {
|
||||
currentMediaId = mediaId
|
||||
endedNotified = false
|
||||
val mediaItem = MediaItem.fromUri(url)
|
||||
if (startPositionSeconds > 0.0) {
|
||||
exoPlayer.setMediaItem(mediaItem, (startPositionSeconds * 1000).toLong())
|
||||
} else {
|
||||
exoPlayer.setMediaItem(mediaItem)
|
||||
}
|
||||
exoPlayer.prepare()
|
||||
exoPlayer.playWhenReady = true
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user