Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 52 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -34,3 +34,55 @@ jobs:
name: test-results
path: automotive/build/test-results/
retention-days: 14

instrumented-tests:
runs-on: ubuntu-latest
timeout-minutes: 60

steps:
- uses: actions/checkout@v4

- uses: actions/setup-java@v4
with:
distribution: temurin
java-version: 17

- uses: gradle/actions/setup-gradle@v4

# Without KVM the x86_64 emulator falls back to software rendering and is too slow.
- name: Enable KVM
run: |
echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' \
| sudo tee /etc/udev/rules.d/99-kvm4all.rules
sudo udevadm control --reload-rules
sudo udevadm trigger --name-match=kvm

# An automotive system image is mandatory, not a preference: the manifest requires
# android.hardware.type.automotive, so the APK will not install on a phone image.
# This action owns AVD creation, boot and teardown — a hand-rolled `adb wait-for-device`
# has no timeout of its own and hangs until the job limit when the emulator never
# registers, which is exactly how the first attempt at this job burned 45 minutes.
- name: Run instrumented tests
uses: reactivecircus/android-emulator-runner@v2
with:
api-level: 35
system-image-api-level: 35-ext15
target: android-automotive
arch: x86_64
profile: automotive_1024p_landscape
cores: 4
ram-size: 4096M
disk-size: 8G
emulator-boot-timeout: 900
emulator-options: -no-window -gpu swiftshader_indirect -no-snapshot -noaudio -no-boot-anim -camera-back none -camera-front none
script: ./gradlew :automotive:connectedDebugAndroidTest

- name: Upload test report
if: always()
uses: actions/upload-artifact@v4
with:
name: instrumented-test-results
path: |
automotive/build/reports/androidTests/
automotive/build/outputs/androidTest-results/
retention-days: 14
24 changes: 24 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,30 @@ Jellyfin music and audiobook player for Android Automotive OS (AAOS) with offlin
./gradlew :automotive:installDebug
```

## Testing

```bash
# Unit tests (Robolectric + mockk) — what CI runs on every PR
./gradlew :automotive:testDebugUnitTest

# End-to-end tests against a simulated Jellyfin server.
# Needs a booted AAOS emulator: the manifest requires android.hardware.type.automotive,
# so the APK will not install on a phone image.
$ANDROID_HOME/emulator/emulator -avd Automotive_Portrait -no-window -no-audio -no-snapshot &
./gradlew :automotive:connectedDebugAndroidTest
```

The E2E suite (`automotive/src/androidTest/`) runs a `FakeJellyfinServer` (MockWebServer) in
the app's own process on `127.0.0.1`, points `DashTuneMusicService` at it by storing an
account and sending `LOGIN_COMMAND`, then drives a real `MediaBrowser` — the same interface
an AAOS head unit uses. It covers browsing, pagination, playback, audiobooks and failure
modes. Fixtures live in `androidTest/.../fake/`; add a route to `FakeJellyfinServer` when the
app starts calling a new Jellyfin endpoint (unhandled routes return 501 and are recorded).

Tests that assert audio actually plays construct the rule with `foregroundForPlayback = true`
— Android 15's audio focus hardening denies focus to an app whose only running component is a
bound service.

## Releasing

1. Bump `versionCode` (increment by 1) and `versionName` (semver) in `automotive/build.gradle.kts`
Expand Down
51 changes: 50 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,7 @@ DashTune/

### Prerequisites
- Android Studio Hedgehog (2023.1.1) or later
- JDK 11 or later
- JDK 17
- Android SDK with API 36

### Build Commands
Expand All @@ -115,6 +115,52 @@ Install to connected device:
./gradlew :automotive:installDebug
```

## Testing

**Neither suite needs a real Jellyfin server.** The end-to-end tests run against a simulated
Jellyfin server that starts inside the app's own process, so they are fully self-contained.

### Unit tests

Robolectric + MockK, no device or emulator required. This is what CI runs on every pull request:

```bash
./gradlew :automotive:testDebugUnitTest
```

Results are written to `automotive/build/test-results/testDebugUnitTest/`.

### End-to-end tests

These bind a real `MediaBrowser` to the real `DashTuneMusicService` — the same interface an AAOS
head unit uses — and cover browsing, pagination, playback, audiobooks and failure handling.

They need a **running Android Automotive emulator**. The manifest requires
`android.hardware.type.automotive`, so the APK will not install on a phone image (see
[Running on AAOS](#running-on-aaos) for creating an automotive AVD):

```bash
# Boot an automotive AVD headlessly
$ANDROID_HOME/emulator/emulator -avd <your_automotive_avd> -no-window -no-audio -no-snapshot &

./gradlew :automotive:connectedDebugAndroidTest
```

Reports are written to `automotive/build/reports/androidTests/connected/debug/index.html`.

### Where the tests live

| Path | Contents |
|------|----------|
| `automotive/src/test/` | Unit tests (ViewModels, repository, media tree, DAO, item factory) |
| `automotive/src/androidTest/` | End-to-end tests |
| `automotive/src/androidTest/.../fake/` | The simulated Jellyfin server and its fixtures |
| `automotive/src/androidTest/.../support/` | Test harness — server startup, sign-in, MediaBrowser helpers |

When the app starts calling a new Jellyfin endpoint, add a route for it to `FakeJellyfinServer`.
Unhandled routes deliberately return HTTP 501 and are recorded, so a missing endpoint surfaces as
a clear failure rather than a silently empty browse list.

## Running on AAOS

### Android Automotive OS Emulator
Expand Down Expand Up @@ -211,6 +257,9 @@ See `gradle/libs.versions.toml` for complete dependency list.

Contributions are welcome! Please feel free to submit pull requests or open issues.

Before opening a pull request, please run the [tests](#testing). CI runs the unit tests and the
end-to-end suite (on an automotive emulator) on every pull request.

## License

TBD
Expand Down
17 changes: 17 additions & 0 deletions automotive/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,17 @@ android {
isIncludeAndroidResources = true
}
}

packaging {
resources {
excludes += setOf(
"/META-INF/{AL2.0,LGPL2.1}",
"/META-INF/versions/9/OSGI-INF/MANIFEST.MF",
"/META-INF/LICENSE.md",
"/META-INF/LICENSE-notice.md",
)
}
}
}

dependencies {
Expand Down Expand Up @@ -99,4 +110,10 @@ dependencies {
testImplementation(libs.androidx.room.testing)
androidTestImplementation(libs.androidx.junit)
androidTestImplementation(libs.androidx.espresso.core)
androidTestImplementation(libs.androidx.test.runner)
androidTestImplementation(libs.androidx.test.rules)
androidTestImplementation(libs.androidx.test.core.ktx)
// Simulated Jellyfin server for the E2E suite.
androidTestImplementation(libs.mockwebserver3)
androidTestImplementation(libs.okhttp.tls)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
package com.chamika.dashtune

import androidx.media3.common.Player
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.chamika.dashtune.fake.RESUME_POSITION_MS
import com.chamika.dashtune.fake.TICKS_PER_MS
import com.chamika.dashtune.media.MediaItemFactory.Companion.BOOKS
import com.chamika.dashtune.media.MediaItemFactory.Companion.IS_AUDIOBOOK_KEY
import com.chamika.dashtune.support.DashTuneE2eRule
import com.chamika.dashtune.support.awaitCondition
import com.chamika.dashtune.support.childTitlesOf
import com.chamika.dashtune.support.childrenOf
import com.chamika.dashtune.support.onMain
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith

/**
* Covers the audiobook path end to end: the Books library lookup through `/UserViews`, the
* collection -> book -> chapter hierarchy, the AAOS completion extras that draw progress bars
* on chapter rows, expansion of a book into an ordered chapter queue, and the position
* round-trip against the server's UserData API.
*/
@RunWith(AndroidJUnit4::class)
class AudiobookE2eTest {

@get:Rule
val dashTune = DashTuneE2eRule(foregroundForPlayback = true)

private val browser get() = dashTune.browser
private val fixture get() = dashTune.fixture

private val completionStatus = "android.media.extra.COMPLETION_STATUS"
private val completionPercentage = "android.media.extra.COMPLETION_PERCENTAGE"

private fun chapterItems() = browser.childrenOf(fixture.multiChapterBook.id.toString())

@Test
fun booksCategoryResolvesTheBooksLibraryAndListsItsContents() {
assertEquals(
listOf(fixture.standaloneBook.name, fixture.bookCollection.name),
browser.childTitlesOf(BOOKS)
)
assertTrue(
"the Books category must be resolved through /UserViews",
dashTune.server.requestsTo("/UserViews").isNotEmpty()
)
}

@Test
fun collectionsDrillDownToBooksThenChapters() {
assertEquals(
listOf(fixture.multiChapterBook.name),
browser.childTitlesOf(fixture.bookCollection.id.toString())
)
assertEquals(fixture.chapters.map { it.name }, chapterItems().map { it.mediaMetadata.title })
}

@Test
fun everyBookItemIsFlaggedAsAudiobook() {
val book = browser.childrenOf(fixture.bookCollection.id.toString()).single()

assertTrue(
"a book with chapters is both browsable and playable",
book.mediaMetadata.isBrowsable == true && book.mediaMetadata.isPlayable == true
)
assertTrue(
"chapters must carry the audiobook flag so shuffle stays disabled",
chapterItems().all { it.mediaMetadata.extras?.getBoolean(IS_AUDIOBOOK_KEY) == true }
)
}

@Test
fun chaptersCarryCompletionExtrasForTheBrowseUi() {
val extras = chapterItems().map { it.mediaMetadata.extras!! }

// 2 = fully played, 1 = partially played, 0 = not played.
assertEquals(
listOf(2, 1, 0),
extras.map { it.getInt(completionStatus) }
)
assertEquals(1.0, extras[0].getDouble(completionPercentage), 0.001)
assertEquals(
"percentage is reported 0..1, from the server's 0..100 PlayedPercentage",
0.4,
extras[1].getDouble(completionPercentage),
0.001
)
}

@Test
fun playingABookExpandsItIntoItsChaptersInOrder() {
val book = browser.childrenOf(fixture.bookCollection.id.toString()).single()
onMain {
browser.setMediaItems(listOf(book))
browser.prepare()
browser.play()
}

awaitCondition(message = { "the book to expand into its chapters" }) {
browser.mediaItemCount == fixture.chapters.size
}
assertEquals(
fixture.chapters.map { it.id.toString() },
onMain { (0 until browser.mediaItemCount).map { browser.getMediaItemAt(it).mediaId } }
)
}

@Test
fun playingAChapterResumesFromThePositionHeldOnTheServer() {
val partiallyPlayed = chapterItems()[1]
onMain {
browser.setMediaItems(listOf(partiallyPlayed))
browser.prepare()
browser.play()
}

awaitCondition(message = { "the chapter queue to be built" }) {
browser.mediaItemCount == fixture.chapters.size
}

assertEquals(
"selecting a chapter keeps its siblings and starts on that chapter",
1,
onMain { browser.currentMediaItemIndex }
)
awaitCondition(message = { "playback to start at the saved server position" }) {
browser.currentPosition >= RESUME_POSITION_MS - 100
}
}

@Test
fun pausingAChapterPersistsThePositionBackToTheServer() {
val chapter = chapterItems()[2]
onMain {
browser.setMediaItems(listOf(chapter))
browser.prepare()
browser.play()
}

awaitCondition(message = { "the chapter to start playing" }) { browser.isPlaying }
// The service samples the position from a 1s poll rather than reading the player at
// pause time, so pausing sooner than that would legitimately persist 0.
awaitCondition(message = { "at least one position poll to land" }) {
browser.currentPosition > 1_500
}

onMain { browser.pause() }

val path = "/UserItems/${chapter.mediaId}/UserData"
awaitCondition(message = { "a UserData write for $path" }) {
dashTune.server.requestsTo(path).isNotEmpty()
}

val body = dashTune.server.requestsTo(path).last().body
val ticks = Regex("\"PlaybackPositionTicks\"\\s*:\\s*(\\d+)").find(body)?.groupValues?.get(1)?.toLong()
?: throw AssertionError("no PlaybackPositionTicks in $body")
assertTrue(
"the saved position should be where playback actually got to, was ${ticks / TICKS_PER_MS}ms",
ticks / TICKS_PER_MS > 500
)
}

@Test
fun aSingleFileBookPlaysItselfRatherThanExpanding() {
val standalone = browser.childrenOf(BOOKS).first { it.mediaId == fixture.standaloneBook.id.toString() }
onMain {
browser.setMediaItems(listOf(standalone))
browser.prepare()
browser.play()
}

awaitCondition(message = { "the standalone book to become ready" }) {
browser.playbackState == Player.STATE_READY
}
assertEquals(1, onMain { browser.mediaItemCount })
assertEquals(
fixture.standaloneBook.id.toString(),
onMain { browser.currentMediaItem?.mediaId }
)
}
}
Loading
Loading