metadata: render phone screenshots from the Navidrome demo library

Replace the placeholder-art screenshots with renders of a real library, so the
store listing shows real album and artist art and names.

The generator (DemoScreenshotTest, run with `gradle :app:demoScreenshots`) syncs
the public Navidrome demo (demo.navidrome.org) into an in-memory database using
the normal SyncService pipeline, derives the theme colours from the fetched
cover art, and renders each screen. The synced data and cover art stay in memory
and are never written to the repository; only the resulting PNGs are committed.
The test is excluded from the unit-test tasks and is not part of CI.

Also reverts the screenshot fixture palette tweak from the previous commit,
which the real art makes unnecessary.
This commit is contained in:
austinried
2026-10-04 00:30:08 +00:00
parent e7ee6fa9a2
commit 7815f211e4
11 changed files with 449 additions and 2 deletions
+1
View File
@@ -24,6 +24,7 @@ Everything comes from the Nix flake devshell; do not install toolchains by hand.
- Format: `gradle :app:ktlintFormat`
- Integration tests: `nix develop .#integration --command nu tools/integration-test.nu` (starts navidrome, gonic, lms and a Nextcloud Music instance, then runs `:app:integrationTest`)
- Screenshots: `gradle :app:recordRoborazziDebug` renders the `*Screen` composables to `app/src/test/screenshots/` (gitignored) plus an HTML report in `app/build/reports/roborazzi/`, for local review. No golden images are committed and `verifyRoborazziDebug` is not part of CI.
- Demo screenshots: `gradle :app:demoScreenshots` does a real sync of the public Navidrome demo (`https://demo.navidrome.org`, `demo`/`demo`) and renders the store screenshots with that library's art and names into `app/src/test/screenshots/demo/` (gitignored). Needs network; not part of CI or the integration suite.
## Testing layout
+17 -1
View File
@@ -100,10 +100,12 @@ val integrationTestClasses =
"com.subtracks.data.sync.SyncServiceIntegrationTest",
)
val demoScreenshotClass = "com.subtracks.ui.DemoScreenshotTest"
tasks.withType<Test>().configureEach {
if (name.endsWith("UnitTest")) {
filter {
integrationTestClasses.forEach { excludeTestsMatching(it) }
(integrationTestClasses + demoScreenshotClass).forEach { excludeTestsMatching(it) }
}
}
testLogging {
@@ -125,6 +127,20 @@ tasks.register<Test>("integrationTest") {
systemProperty("prune.musicDir", providers.gradleProperty("pruneMusicDir").getOrElse(""))
}
tasks.register<Test>("demoScreenshots") {
description = "Records the store screenshots from a real sync of the public Navidrome demo library"
group = "verification"
val unitTest = tasks.named<Test>("testDebugUnitTest")
dependsOn("compileDebugUnitTestKotlin")
testClassesDirs = files(provider { unitTest.get().testClassesDirs })
classpath = files(provider { unitTest.get().classpath })
filter {
includeTestsMatching(demoScreenshotClass)
}
systemProperty("roborazzi.test.record", "true")
outputs.upToDateWhen { false }
}
dependencies {
implementation(platform(libs.androidx.compose.bom))
implementation(libs.androidx.core.ktx)
@@ -741,7 +741,7 @@ private object Fixtures {
fun color(offset: Float): Int =
Color.HSVToColor(
floatArrayOf(((base + offset) % 360f + 360f) % 360f, 0.42f, 0.72f),
floatArrayOf(((base + offset) % 360f + 360f) % 360f, 0.88f, 0.92f),
)
val palette =
@@ -0,0 +1,430 @@
package com.subtracks.ui
import android.content.Context
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.graphics.Color
import androidx.activity.ComponentActivity
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.Surface
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule
import androidx.compose.ui.test.onRoot
import androidx.paging.PagingData
import androidx.palette.graphics.Palette
import androidx.room3.Room
import androidx.sqlite.driver.bundled.BundledSQLiteDriver
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import coil3.ImageLoader
import coil3.SingletonImageLoader
import coil3.annotation.DelicateCoilApi
import coil3.asImage
import coil3.test.FakeImageLoaderEngine
import com.github.takahirom.roborazzi.RobolectricDeviceQualifiers
import com.github.takahirom.roborazzi.captureRoboImage
import com.subtracks.data.db.SubtracksDatabase
import com.subtracks.data.model.Album
import com.subtracks.data.model.Artist
import com.subtracks.data.model.CoverArtRef
import com.subtracks.data.model.Playlist
import com.subtracks.data.model.Song
import com.subtracks.data.model.Source
import com.subtracks.data.model.coverArtKey
import com.subtracks.data.prefs.ListQuery
import com.subtracks.data.source.subsonic.SubsonicSource
import com.subtracks.data.source.subsonic.TestServer
import com.subtracks.data.source.subsonic.TestServers
import com.subtracks.data.sync.SyncService
import com.subtracks.playback.PlaybackState
import com.subtracks.playback.QueueItem
import com.subtracks.ui.home.HomeFeed
import com.subtracks.ui.home.HomeScreen
import com.subtracks.ui.library.AlbumDetailScreen
import com.subtracks.ui.library.ArtistDetailScreen
import com.subtracks.ui.library.LibraryScreen
import com.subtracks.ui.library.LibraryTab
import com.subtracks.ui.library.sortOptionsFor
import com.subtracks.ui.playback.NowPlayingScreen
import com.subtracks.ui.theme.ArtworkColors
import com.subtracks.ui.theme.SubtracksTheme
import com.subtracks.ui.theme.artworkColorsFromSeeds
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.runBlocking
import okhttp3.OkHttpClient
import okhttp3.Request
import org.junit.After
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.annotation.Config
import org.robolectric.annotation.GraphicsMode
/**
* Records the store screenshots from a real sync of the public Navidrome demo library. It is not run
* by `:*UnitTest` or the integration suite; run it explicitly with `gradle :app:demoScreenshots`.
* The synced database and the downloaded cover art live only in memory and are never written down.
*/
@OptIn(DelicateCoilApi::class)
@RunWith(AndroidJUnit4::class)
@GraphicsMode(GraphicsMode.Mode.NATIVE)
@Config(qualifiers = RobolectricDeviceQualifiers.PixelXL)
class DemoScreenshotTest {
@get:Rule
val composeRule = createAndroidComposeRule<ComponentActivity>()
private var screen by mutableStateOf(Screen.Home)
@Before
fun resetImageLoader() {
SingletonImageLoader.reset()
}
@After
fun clearImageLoader() {
SingletonImageLoader.reset()
}
@Test
fun recordDemo() {
val demo = DemoData.load()
SingletonImageLoader.setUnsafe(
ImageLoader
.Builder(ApplicationProvider.getApplicationContext())
.components { add(demo.engine) }
.build(),
)
composeRule.setContent {
SubtracksTheme {
Surface(Modifier.fillMaxSize()) {
when (screen) {
Screen.Home -> {
HomeScreen(
feed = demo.feed,
coverArt = demo.coverArt,
playingSongId = demo.song.id,
modifier = Modifier.fillMaxSize(),
)
}
Screen.NowPlaying -> {
NowPlayingScreen(
state = demo.playbackState,
positionMs = demo.positionMs,
title = demo.album.name,
coverArt = demo.coverArt(demo.album.coverArt, false),
artwork = demo.artwork(demo.album.coverArt),
onBack = {},
onQueue = {},
onPlayPause = {},
onNext = {},
onPrevious = {},
onSeek = {},
modifier = Modifier.fillMaxSize(),
)
}
Screen.Albums -> {
AlbumsScreen(demo.albums, demo.coverArt, demo.artwork(demo.albums.first().coverArt))
}
Screen.AlbumDetail -> {
AlbumDetailScreen(
album = demo.album,
songs = demo.albumSongs,
coverArt = demo.coverArt,
artwork = demo.artwork(demo.album.coverArt),
onBack = {},
onSongClick = {},
playingSongId = demo.song.id,
modifier = Modifier.fillMaxSize(),
)
}
Screen.ArtistDetail -> {
ArtistDetailScreen(
artist = demo.artist,
albums = demo.artistAlbums,
art = demo.coverArt(demo.artist.coverArt, false),
artThumbnail = demo.coverArt(demo.artist.coverArt, true),
artwork = demo.artwork(demo.artist.coverArt),
coverArt = demo.coverArt,
onBack = {},
onAlbumClick = {},
modifier = Modifier.fillMaxSize(),
)
}
Screen.Artists -> {
LibraryScreen(
selectedTab = LibraryTab.Artists,
onTabSelected = {},
albums = remember { flowOf(PagingData.empty<Album>()) },
artists = remember { flowOf(PagingData.from(demo.artists)) },
playlists = remember { flowOf(PagingData.empty<Playlist>()) },
coverArt = demo.coverArt,
onAlbumClick = {},
onArtistClick = {},
onPlaylistClick = {},
onSync = {},
onOpenSettings = {},
artwork = demo.artwork(demo.artists.first().coverArt),
modifier = Modifier.fillMaxSize(),
)
}
Screen.Search -> {
LibraryScreen(
selectedTab = LibraryTab.Albums,
onTabSelected = {},
albums = remember { flowOf(PagingData.from(demo.searchAlbums)) },
artists = remember { flowOf(PagingData.empty<Artist>()) },
playlists = remember { flowOf(PagingData.empty<Playlist>()) },
coverArt = demo.coverArt,
onAlbumClick = {},
onArtistClick = {},
onPlaylistClick = {},
onSync = {},
onOpenSettings = {},
listQuery = ListQuery("Name"),
sortOptions = sortOptionsFor(LibraryTab.Albums),
starredSupported = true,
search = DEMO_SEARCH,
onSearchChange = {},
artwork = demo.artwork(demo.searchAlbums.first().coverArt),
modifier = Modifier.fillMaxSize(),
)
}
}
}
}
}
capture(Screen.Home, "01_home.png")
capture(Screen.NowPlaying, "02_now-playing.png")
capture(Screen.Albums, "03_library-albums.png")
capture(Screen.AlbumDetail, "04_album.png")
capture(Screen.ArtistDetail, "05_artist.png")
capture(Screen.Artists, "06_library-artists.png")
capture(Screen.Search, "07_search.png")
}
@Composable
private fun AlbumsScreen(
albums: List<Album>,
coverArt: (String?, Boolean) -> CoverArtRef?,
artwork: ArtworkColors,
) {
LibraryScreen(
selectedTab = LibraryTab.Albums,
onTabSelected = {},
albums = remember { flowOf(PagingData.from(albums)) },
artists = remember { flowOf(PagingData.empty<Artist>()) },
playlists = remember { flowOf(PagingData.empty<Playlist>()) },
coverArt = coverArt,
onAlbumClick = {},
onArtistClick = {},
onPlaylistClick = {},
onSync = {},
onOpenSettings = {},
listQuery = ListQuery("Name"),
sortOptions = sortOptionsFor(LibraryTab.Albums),
starredSupported = true,
artwork = artwork,
modifier = Modifier.fillMaxSize(),
)
}
private fun capture(
target: Screen,
file: String,
) {
composeRule.runOnIdle { screen = target }
repeat(3) {
composeRule.mainClock.advanceTimeBy(500)
composeRule.waitForIdle()
}
composeRule.onRoot().captureRoboImage("src/test/screenshots/demo/$file")
}
private enum class Screen { Home, NowPlaying, Albums, AlbumDetail, ArtistDetail, Artists, Search }
private class DemoData(
val albums: List<Album>,
val artists: List<Artist>,
val feed: HomeFeed,
val song: Song,
val album: Album,
val albumSongs: List<Song>,
val artist: Artist,
val artistAlbums: List<Album>,
val searchAlbums: List<Album>,
val coverArt: (String?, Boolean) -> CoverArtRef?,
val engine: FakeImageLoaderEngine,
private val bitmaps: Map<String, Bitmap>,
) {
val playbackState =
PlaybackState(
item =
QueueItem(
id = song.id,
title = song.title,
artist = song.artist,
album = song.album,
coverArtId = album.coverArt,
),
isPlaying = true,
durationMs = (song.duration ?: 0) * 1000,
hasNext = true,
hasPrevious = false,
)
val positionMs: Long = (song.duration ?: 0) * 1000 * 35 / 100
fun artwork(coverArtId: String?): ArtworkColors {
val bitmap = coverArtId?.let(bitmaps::get) ?: return artworkColorsFromSeeds(FALLBACK_SEED, null)
val palette = Palette.from(bitmap).generate()
val primary =
palette.vibrantSwatch
?: palette.lightVibrantSwatch
?: palette.darkVibrantSwatch
?: palette.mutedSwatch
?: palette.dominantSwatch
return artworkColorsFromSeeds(primary?.rgb ?: FALLBACK_SEED, null)
}
companion object {
private const val FALLBACK_SEED = 0xFF3A7BD5.toInt()
private const val DEMO_BASE = "https://demo.navidrome.org/"
fun load(): DemoData =
runBlocking {
val context = ApplicationProvider.getApplicationContext<Context>()
val http = OkHttpClient()
val server =
TestServer(
name = "demo",
baseUrl = DEMO_BASE,
username = "demo",
password = "demo",
supportsTokenAuth = true,
supportsGenres = true,
)
val source: SubsonicSource = TestServers.source(server)
val db =
Room
.inMemoryDatabaseBuilder(context, SubtracksDatabase::class.java)
.setDriver(BundledSQLiteDriver())
.build()
db.sourcesDao().upsertSource(
Source(id = 1, name = "Navidrome Demo", address = server.baseUrl, isActive = true, createdAt = 0),
)
SyncService(db, source).sync()
val dao = db.libraryDao()
val albums = dao.recentlyAddedAlbums(1, 500).first().sortedBy { it.name.lowercase() }
val artists = dao.artistIds(1).mapNotNull { dao.artist(1, it).first() }
val playlists = dao.playlistIds(1).mapNotNull { dao.playlist(1, it).first() }
val album = albums.firstOrNull { it.name == "First Words" } ?: albums.first()
val albumSongs = dao.songsByAlbum(1, album.id).first()
val song = albumSongs.getOrNull(1) ?: albumSongs.first()
val artist =
artists.maxByOrNull { it.albumCount }
?: artists.first()
val artistAlbums = dao.albumsForArtist(1, artist.id).first()
val feed =
HomeFeed(
recentlyPlayedAlbums = dao.recentlyPlayedAlbums(1, 10).first(),
recentlyPlayedArtists = dao.recentlyPlayedArtists(1, 10).first(),
mostPlayedAlbums = dao.mostPlayedAlbums(1, 10).first(),
mostPlayedArtists = dao.mostPlayedArtists(1, 10).first(),
genres = dao.genresByMostPlayed(1).first(),
decades = dao.decades(1).first(),
recentlyStarredSongs = dao.recentlyStarredSongs(1, 10).first(),
recentlyAddedAlbums = albums.take(10),
rediscoverAlbums = dao.rediscoverAlbums(1, System.currentTimeMillis() / 1000, 10).first(),
)
val searchAlbums =
albums
.filter {
it.name.contains(DEMO_SEARCH, ignoreCase = true) ||
(it.albumArtist ?: "").contains(DEMO_SEARCH, ignoreCase = true)
}.ifEmpty { albums.take(4) }
val coverArtIds =
(albums.map { it.coverArt } + artists.map { it.coverArt } + playlists.map { it.coverArt })
.filterNotNull()
.distinct()
val bitmaps = mutableMapOf<String, Bitmap>()
val builder = FakeImageLoaderEngine.Builder()
coverArtIds.forEach { id ->
val bitmap = fetchBitmap(http, source, id) ?: return@forEach
bitmaps[id] = bitmap
val image = bitmap.asImage()
listOf(false, true).forEach { thumbnail ->
source.coverArtUri(id, thumbnail)?.let { builder.intercept(it.toString(), image) }
}
}
val fallback =
Bitmap
.createBitmap(1, 1, Bitmap.Config.ARGB_8888)
.apply { eraseColor(Color.GRAY) }
.asImage()
val engine = builder.default(fallback).build()
val coverArt: (String?, Boolean) -> CoverArtRef? = { id, thumbnail ->
id?.let {
source.coverArtUri(it, thumbnail)?.let { uri ->
CoverArtRef(uri.toString(), coverArtKey(1, it, thumbnail))
}
}
}
db.close()
DemoData(
albums = albums,
artists = artists,
feed = feed,
song = song,
album = album,
albumSongs = albumSongs,
artist = artist,
artistAlbums = artistAlbums,
searchAlbums = searchAlbums,
coverArt = coverArt,
engine = engine,
bitmaps = bitmaps,
)
}
private fun fetchBitmap(
http: OkHttpClient,
source: SubsonicSource,
id: String,
): Bitmap? =
try {
val url = source.coverArtUri(id, false) ?: return null
val bytes = http.newCall(Request.Builder().url(url).build()).execute().use { it.body.bytes() }
BitmapFactory.decodeByteArray(bytes, 0, bytes.size)
} catch (_: Exception) {
null
}
}
}
}
private const val DEMO_SEARCH = "the"
Binary file not shown.

Before

Width:  |  Height:  |  Size: 139 KiB

After

Width:  |  Height:  |  Size: 444 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 MiB

After

Width:  |  Height:  |  Size: 490 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 532 KiB

After

Width:  |  Height:  |  Size: 878 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 MiB

After

Width:  |  Height:  |  Size: 521 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 147 KiB

After

Width:  |  Height:  |  Size: 160 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 54 KiB

After

Width:  |  Height:  |  Size: 1.1 MiB