docs: Android comments say what the app does, not how it came to be (#6277)
release / govulncheck (push) Successful in 23s
release / web (push) Successful in 1m13s
release / go (push) Successful in 1m42s
release / integration (push) Successful in 5m19s
release / android (push) Successful in 6m2s
release / Build signed APK (releases and dev) (push) Successful in 6m15s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 16s
release / Verify release artifacts (tag releases only) (push) Skipped

Every flagged comment block in the Android app, its tests and its XML/TOML
config, rewritten with the cited issue in view: the constraint is kept, the
story behind it dropped, and issue, milestone, rule, phase and drift ids,
dates and operator quotes removed. "Mirrors the Flutter client's X"
pointers are gone; that client is no longer in the repo.

Comments that no longer matched the code, corrected:
- SyncController: a 410 resets the cursor and re-pulls; it never dropped
  the three local caches.
- PlayerFactory, PlayerModule, AudioCacheIndexEntity, CacheConfig,
  CacheSource: there is no eviction worker and no liked-track bucket;
  SimpleCache evicts by plain LRU over the rolling cap, and the liked cap
  is stored but unenforced.
- CacheSettings, StorageCard: the prefetcher exists (AudioPrefetcher);
  pin-on-like still does not.
- LibraryApi, LibraryRepository, BrowseViewModel: the cache now carries
  the missing-file mark; the browse indexes stay server-backed for one
  source of grouping.
- OutputRoute: UPnP routes exist; only CAST and SONOS are reserved.
- PlayerFactory, RemotePlayerState, OutputPickerController: a failed
  transport command does not drop the UPnP route.
- LibraryScreen: History, Liked and Hidden are real tabs, not placeholders.
- AVTransportClient: the extend loop lives in SonosQueueLoader.
- "the operator" meaning the listener now says "the user".

Verified with a Kotlin lexer comparing every file's non-comment tokens
against HEAD: 394 files, 0 differences.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-23 12:59:07 -04:00
co-authored by Claude Opus 5.5
parent 9f588b6d61
commit ae7e05c579
228 changed files with 844 additions and 1088 deletions
+9 -14
View File
@@ -1,9 +1,9 @@
plugins {
alias(libs.plugins.android.application)
// kotlin-android NOT applied: AGP 9 enables built-in Kotlin by default,
// and KSP 2.3.x supports it (PR #2674, merged Oct 2025). serialization
// + compose-compiler are language-level Kotlin compiler plugins and
// still need explicit application.
// and KSP 2.3.x supports it. serialization + compose-compiler are
// language-level Kotlin compiler plugins and still need explicit
// application.
alias(libs.plugins.kotlin.serialization)
alias(libs.plugins.compose.compiler)
alias(libs.plugins.ksp)
@@ -31,14 +31,9 @@ android {
//
// versionCode is minutes since 2020-01-01 at BUILD time. It is the
// value the platform decides installs by, so it must be monotonic by
// construction.
//
// This comment used to say versionCode was a commit count and that it
// was "monotonic forever". It was neither — a commit count runs ahead
// on `dev`, so a dev build outranked the `main` release meant to
// replace it and Android refused the install as a downgrade. Worth
// knowing the claim was here, stated as a reassurance, while the bug
// it denied was live.
// construction. Not a commit count: that runs ahead on `dev`, so a dev
// build would outrank the `main` release meant to replace it and
// Android would refuse the install as a downgrade.
val versionNameOverride =
(project.findProperty("MINSTREL_VERSION_NAME") as String?)?.takeIf { it.isNotBlank() }
val versionCodeOverride =
@@ -73,9 +68,9 @@ android {
"proguard-rules.pro",
)
// Signed with the release key or not at all. Falling back to the
// debug key made a missing secret into a published APK that no
// install could ever update (family idea #5103, practice 2). An
// unsigned build installs nowhere, so the gap shows at once.
// debug key would turn a missing secret into a published APK that no
// install could ever update. An unsigned build installs nowhere, so
// the gap shows at once.
signingConfig =
if (System.getenv("ANDROID_KEYSTORE_PATH").isNullOrEmpty()) {
null
+3 -6
View File
@@ -8,7 +8,7 @@
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<!-- Notifications when the app is closed (M489 #5347): the delivery
<!-- Notifications when the app is closed: the delivery
service, and starting it again after a reboot or an update. -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
@@ -84,11 +84,8 @@
</intent-filter>
</receiver>
<!-- The FileProvider that used to live here existed solely to expose the
downloaded update APK as a content:// URI for the old ACTION_VIEW
install intent. A PackageInstaller session takes a stream instead,
so both the provider and res/xml/file_paths.xml are gone — nothing
else in the app ever used that authority. -->
<!-- No FileProvider: the self-update streams the downloaded APK into a
PackageInstaller session, so nothing needs a content:// URI. -->
<!-- On-demand WorkManager initialization: MinstrelApplication
implements Configuration.Provider and supplies the
@@ -106,7 +106,7 @@ class MainActivity : ComponentActivity() {
}
/**
* Android 13+ asks before an app may post notifications (M489 #5347).
* Android 13+ asks before an app may post notifications.
* Asked once delivery is wanted (signed in, background delivery on),
* each launch until answered: after a second "no" the system stops
* showing the prompt by itself, and Settings → Notifications links to
@@ -79,7 +79,7 @@ class MinstrelApplication :
/**
* Same construct-the-singleton trick — DeliveryLauncher starts and stops
* the background-delivery service and runs the notification catch-up on
* every nudge and reconnect (M489 #5347). Without this @Inject no phone
* every nudge and reconnect. Without this @Inject no phone
* notification would ever be posted.
*/
@Suppress("unused") @Inject lateinit var deliveryLauncher: DeliveryLauncher
@@ -171,7 +171,7 @@ class MinstrelApplication :
*/
@Suppress("unused") @Inject lateinit var liveEventsDispatcher: LiveEventsDispatcher
// Device diagnostics (M9). The reporter gates itself on the account's
// Device diagnostics. The reporter gates itself on the account's
// debug flag; the uploader drains its buffer on a tick / recovery.
@Suppress("unused") @Inject lateinit var diagnosticsReporter: DiagnosticsReporter
@Suppress("unused") @Inject lateinit var diagnosticsUploader: DiagnosticsUploader
@@ -181,7 +181,7 @@ class MinstrelApplication :
override fun onCreate() {
super.onCreate()
// Debug builds get the full DebugTree (verbose). Release builds
// get a WARN+ tree so operator-driven diagnosis via `adb logcat`
// get a WARN+ tree so diagnosis via `adb logcat`
// still surfaces UPnP / cast failures, OkHttp errors, and our
// own Timber.w / Timber.e calls — without the chatty DEBUG /
// INFO traffic flooding the buffer in production.
@@ -229,10 +229,9 @@ class MinstrelApplication :
* before Coil makes its first request.
*
* Crossfade is set here rather than per-call so every cover surface
* in the app fades its artwork in instead of snapping it. Art
* landing a beat after its tile was the most visible pop-in on Home
* (issue #2327); `ServerImage` fades its placeholder out over the
* same window so the two read as one cross-dissolve.
* in the app fades its artwork in instead of snapping it, and
* `ServerImage` fades its placeholder out over the same window so the
* two read as one cross-dissolve.
*/
override fun newImageLoader(context: android.content.Context): ImageLoader =
ImageLoader.Builder(context)
@@ -11,10 +11,9 @@ import javax.inject.Inject
import javax.inject.Singleton
/**
* Read-through accessor for the admin quarantine queue. Mirrors the
* Flutter `AdminQuarantineController`. No Room cache — same rationale
* as AdminRequests. Three resolution actions all delete the row from
* the queue once the server accepts them.
* Read-through accessor for the admin quarantine queue. No Room cache —
* same rationale as AdminRequests. Three resolution actions all delete
* the row from the queue once the server accepts them.
*/
@Singleton
class AdminQuarantineRepository @Inject constructor(
@@ -14,7 +14,7 @@ import javax.inject.Singleton
*
* No Room caching — admin actions are infrequent and don't benefit
* from offline scrollback. `approve` and `reject` fire direct REST
* (no mutation queue) because operator confirmation is point-and-shoot;
* (no mutation queue) because admin confirmation is point-and-shoot;
* if the request fails the UI surfaces the error and rolls back.
*/
@Singleton
@@ -12,7 +12,7 @@ import javax.inject.Inject
import javax.inject.Singleton
/**
* Read-through accessor for the tag-enrichment provider settings (#1521).
* Read-through accessor for the tag-enrichment provider settings.
* No Room caching — admin settings are infrequent point-and-shoot edits;
* mutations fire direct REST and the ViewModel reconciles on failure.
* Exceptions propagate to the ViewModel (which maps them via ErrorCopy).
@@ -13,8 +13,8 @@ import javax.inject.Singleton
/**
* Read-through accessor for the admin users list + the four per-user
* mutations. Mirrors Flutter's `AdminUsersController`. Same
* no-Room-cache, no-MutationQueue pattern as the other admin slices.
* mutations. Same no-Room-cache, no-MutationQueue pattern as the other
* admin repositories.
*/
@Singleton
class AdminUsersRepository @Inject constructor(
@@ -39,7 +39,7 @@ class AdminInvitesViewModel @Inject constructor(
init {
refresh()
// Screen-level auto-recovery (issue #1245): reload a failed list
// Screen-level auto-recovery: reload a failed list
// when server health returns instead of waiting for a manual pull.
viewModelScope.launch {
networkStatus.recoveries().collect {
@@ -40,8 +40,8 @@ class AdminQuarantineViewModel @Inject constructor(
/**
* One-shot messages for the screen's snackbar. A failed action has to say
* why: the row quietly reappearing reads as a glitch, and for a Delete
* file refused by a read-only library it hides the one thing the
* operator can fix (#3918).
* file refused by a read-only library it hides the one thing an
* admin can fix.
*/
private val transientMessagesChannel = Channel<String>(Channel.BUFFERED)
val transientMessages: Flow<String> = transientMessagesChannel.receiveAsFlow()
@@ -53,7 +53,7 @@ class AdminQuarantineViewModel @Inject constructor(
.filter { it.kind.startsWith("quarantine.") }
.collect { refresh() }
}
// Screen-level auto-recovery (issue #1245): reload a failed list
// Screen-level auto-recovery: reload a failed list
// when server health returns instead of waiting for a manual pull.
viewModelScope.launch {
networkStatus.recoveries().collect {
@@ -43,7 +43,7 @@ class AdminRequestsViewModel @Inject constructor(
.filter { it.kind in RELEVANT_EVENT_KINDS }
.collect { refresh() }
}
// Screen-level auto-recovery (issue #1245): reload a failed list
// Screen-level auto-recovery: reload a failed list
// when server health returns instead of waiting for a manual pull.
viewModelScope.launch {
networkStatus.recoveries().collect {
@@ -38,7 +38,7 @@ class AdminTagSourcesViewModel @Inject constructor(
init {
refresh()
// Screen-level auto-recovery: reload a failed list when server
// health returns instead of waiting for a manual pull (issue #1245).
// health returns instead of waiting for a manual pull.
viewModelScope.launch {
networkStatus.recoveries().collect {
if (internal.value is AdminTagSourcesUiState.Error) refresh()
@@ -33,7 +33,7 @@ class AdminUsersViewModel @Inject constructor(
init {
refresh()
// Screen-level auto-recovery (issue #1245): reload a failed list
// Screen-level auto-recovery: reload a failed list
// when server health returns instead of waiting for a manual pull.
viewModelScope.launch {
networkStatus.recoveries().collect {
@@ -12,18 +12,12 @@ import javax.inject.Singleton
* flow), and clears the store on 401 so downstream code can react
* to logout.
*
* Mirrors the Flutter Dio interceptor pattern.
*
* Scoped to the [BaseUrlInterceptor.PLACEHOLDER_HOST] sentinel host
* the same way BaseUrlInterceptor is. Drift #568 / #569 caught two
* leaks here: (1) the session cookie was being attached to every
* external request the shared OkHttpClient services — including
* Coil image fetches to artwork.musicbrainz.org / coverartarchive.org
* — exposing the session
* identifier to third-party logging; (2) a 401 from any of those
* external hosts silently wiped the user's Minstrel session.
* Restricting both attach + clear to placeholder-host requests
* closes both gaps.
* the same way BaseUrlInterceptor is. The shared OkHttpClient also
* serves Coil's image fetches to external hosts such as
* artwork.musicbrainz.org and coverartarchive.org; unscoped, this
* would hand the session identifier to third-party logging, and a 401
* from any of those hosts would sign the user out of Minstrel.
*/
@Singleton
class AuthCookieInterceptor @Inject constructor(
@@ -17,15 +17,14 @@ import javax.inject.Singleton
*
* This interceptor closes the gap: Retrofit can stay built with the
* placeholder forever, and every actual Minstrel-bound request gets
* retargeted at the live AuthStore value. Lets the user change
* server URL in Settings without an app relaunch (Phase 11 wiring).
* retargeted at the live AuthStore value, so the user can change the
* server URL in Settings without an app relaunch.
*
* Only requests whose host is the [PLACEHOLDER_HOST] sentinel are
* rewritten. Absolute external URLs — artwork from
* artwork.musicbrainz.org / coverartarchive.org, for example — must
* reach their authored host unchanged; rewriting them to the
* Minstrel host produced 404s the user saw as missing Discover
* suggestion covers.
* reach their authored host unchanged: rewritten to the Minstrel host
* they 404, and Discover suggestions show no covers.
*
* No-op when the stored base URL is unparseable (falls through to
* whatever Retrofit had) — that case shows up as a transport-level
@@ -16,11 +16,10 @@ object MinstrelServerRequest
/**
* Plain `http://` to the Minstrel server is allowed only when the connection
* actually lands on a private address (family security baseline #5105,
* practice 13).
* actually lands on a private address.
*
* Cleartext stays permitted app-wide for LAN servers and UPnP
* (network_security_config.xml, #2439), but a password or session cookie sent
* (network_security_config.xml), but a password or session cookie sent
* over plain HTTP to a public address can be read by anyone on the path.
*
* Checked per connection, on the address the socket really reached, not on
@@ -44,9 +44,9 @@ object ErrorCopy {
}
/**
* Codes whose server message carries specifics the operator needs in
* Codes whose server message carries specifics an admin needs in
* order to act — which directory, which uid — that fixed copy cannot say.
* For these the message follows the copy (#3918). Kept to a named set on
* For these the message follows the copy. Kept to a named set on
* purpose: most server messages are internal detail. Mirrors web's
* errors.ts.
*/
@@ -57,16 +57,15 @@ object NetworkModule {
.addInterceptor(reachability)
// AuthCookieInterceptor MUST run before BaseUrlInterceptor.
// Both scope on `host == PLACEHOLDER_HOST` to distinguish
// Minstrel-server requests from external image fetches
// (drift #568 / #569). If BaseUrlInterceptor runs first it
// rewrites the host to the real server before auth sees the
// request, auth's placeholder check fails, and the session
// cookie is neither attached on outgoing requests nor
// captured from Set-Cookie on login — fresh installs get
// stuck at the Welcome screen. Auth first means it sees
// Minstrel-server requests from external image fetches. If
// BaseUrlInterceptor runs first it rewrites the host to the real
// server before auth sees the request, auth's placeholder check
// fails, and the session cookie is neither attached on outgoing
// requests nor captured from Set-Cookie on login — fresh installs
// get stuck at the Welcome screen. Auth first means it sees
// placeholder.invalid, attaches/captures correctly, then
// BaseUrlInterceptor retargets to the live AuthStore host
// for transport.
// BaseUrlInterceptor retargets to the live AuthStore host for
// transport.
.addInterceptor(auth)
.addInterceptor(baseUrl)
.addInterceptor(logging)
@@ -27,9 +27,8 @@ interface AdminInvitesApi {
}
/**
* Body for `POST /api/admin/invites`. The Flutter client omits the
* field when note is empty; Kotlin/serialization just serializes
* null which the server treats identically.
* Body for `POST /api/admin/invites`. An empty note is sent as null,
* which the server treats the same as an absent field.
*/
@Serializable
data class CreateInviteBody(val note: String? = null)
@@ -12,9 +12,9 @@ import retrofit2.http.POST
import retrofit2.http.Path
/**
* Retrofit interface for `/api/admin/tag-sources` (#1521) — the admin
* surface for the tag-enrichment provider settings (#1490). Mirrors the
* web integrations "Tag enrichment sources" card. Same shape as the
* Retrofit interface for `/api/admin/tag-sources` — the admin surface
* for the tag-enrichment provider settings. Mirrors the web
* integrations "Tag enrichment sources" card. Same shape as the
* cover-sources admin surface; kept independent so a new tag source is
* added without touching art settings.
*/
@@ -30,7 +30,7 @@ interface CastApi {
* the 21_600 default (6h) is long enough to play through any typical
* track without re-minting mid-playback.
*
* [level] asks for the leveled stream (M464 #5001): the track rendered at
* [level] asks for the leveled stream: the track rendered at
* the user's loudness gain, which the server works out from their setting.
* [asAlbum] says the track plays among its album in order, which picks
* album gain in auto mode. [prerender] says the speaker will fetch it
@@ -7,7 +7,7 @@ import retrofit2.http.Body
import retrofit2.http.POST
/**
* Retrofit interface for device diagnostics ingest (M9). One call
* Retrofit interface for device diagnostics ingest. One call
* uploads a batch of buffered events. The server stores them only when
* the account's debug_mode_enabled flag is on (it returns 204 otherwise,
* which the uploader treats as success so it can drop the batch).
@@ -19,8 +19,7 @@ import retrofit2.http.Query
* second, so the Discover screen searches when the query is submitted,
* not on every keystroke.
*
* Cancel + my-requests-list endpoints live on RequestsApi (Phase 10
* Commit B), not here.
* Cancel + my-requests-list endpoints live on RequestsApi, not here.
*/
interface DiscoverApi {
@GET("api/discover/suggestions")
@@ -56,16 +56,15 @@ interface LibraryApi {
@GET("api/library/shuffle")
suspend fun shuffleLibrary(@Query("limit") limit: Int = 100): List<TrackWire>
// Browse axes (#367). Both indexes are unpaged by design: the client needs
// Browse axes. Both indexes are unpaged by design: the client needs
// the whole set to render a browsable picker, and even a messy library
// yields hundreds of rows, not thousands.
//
// These read the server rather than the local cache on purpose. The cache
// is a full mirror of the library, but /api/library/sync ships tracks whose
// files are missing and carries no flag for it (#2704), while the browse
// index filters them out -- so a locally-computed index would disagree with
// the server's and with the web client. One source of truth wins over
// offline capability here until #2704 is resolved.
// These read the server rather than the local cache on purpose: the
// server's index leaves out tracks whose files are missing and is the
// one the web client shows, so the two clients agree. The cache carries
// the same mark (cached_tracks.missing), but an index built from it
// would be a second copy of the server's grouping that could drift.
@GET("api/library/genres")
suspend fun getGenres(): List<GenreCountWire>
@@ -92,9 +92,7 @@ data class ChangePasswordRequest(
/**
* Body for `PUT /api/me/listenbrainz`. Either `token` or `enabled`
* is set; the server treats other fields as untouched. Mirrors
* Flutter's `setListenBrainzToken` / `setListenBrainzEnabled` split
* over a single endpoint shape.
* is set; the server treats other fields as untouched.
*/
@Serializable
data class ListenBrainzPutBody(
@@ -10,7 +10,7 @@ import retrofit2.http.Path
import retrofit2.http.Query
/**
* The notifications inbox and its per-user settings (M489). The server
* The notifications inbox and its per-user settings. The server
* renders each notice's title, body and link, so the app shows them as given.
*/
interface NotificationsApi {
@@ -49,7 +49,7 @@ interface PlaylistsApi {
suspend fun refreshSystem(@Path("kind") variant: String): RefreshSystemResponse
/**
* GET /api/playlists/system/{kind}/shuffle (#415 / #411 R2). Returns
* GET /api/playlists/system/{kind}/shuffle. Returns
* the system playlist's tracks in rotation-aware order without
* rebuilding — used by the Home play-button overlay so taps on For
* You / Discover / Today's mix advance rotation rather than picking
@@ -60,8 +60,8 @@ interface PlaylistsApi {
}
/**
* POST /api/playlists/{id}/tracks body. Server expects snake_case
* `track_ids` per the Flutter wire shape.
* POST /api/playlists/{id}/tracks body. The server expects snake_case
* `track_ids`.
*/
@Serializable
data class AppendTracksRequest(
@@ -13,7 +13,7 @@ import retrofit2.http.Path
*
* Both flag and unflag are user-scoped — callers act on their own
* quarantine entries. The cross-user admin surface is a separate
* `/api/admin/quarantine` route (Phase 10 Commit D).
* `/api/admin/quarantine` route.
*/
interface QuarantineApi {
@GET("api/quarantine/mine")
@@ -4,7 +4,7 @@ import com.fabledsword.minstrel.models.wire.ReplayGainResponseWire
import retrofit2.http.GET
import retrofit2.http.Query
/** The player's loudness lookup (#4997), kept apart from the browse surface in [LibraryApi]. */
/** The player's loudness lookup, kept apart from the browse surface in [LibraryApi]. */
interface ReplayGainApi {
/**
* ReplayGain values for up to 200 comma-separated track ids. An id
@@ -10,7 +10,7 @@ import retrofit2.http.Path
*
* Server scopes results to the caller — admins see only their own
* requests through this endpoint. The cross-user admin view lives on
* a separate `/api/admin/requests` route (Phase 10 Commit D).
* a separate `/api/admin/requests` route.
*
* The DELETE endpoint returns the cancelled row (not 204) so the UI
* can patch local state without a refetch.
@@ -6,7 +6,7 @@ import retrofit2.http.GET
import retrofit2.http.Query
/**
* Retrofit interface for `/api/library/sync` (#357). The endpoint
* Retrofit interface for `/api/library/sync`. The endpoint
* returns three possible status codes that the SyncController
* branches on:
* - 200: parsed body with cursor + upserts + deletes
@@ -57,7 +57,7 @@ class AuthController @Inject constructor(
}
}
// Refresh from /api/me on startup so a remotely-changed account
// flag (e.g. admin enabling debug mode, M9) reaches the device
// flag (e.g. an admin enabling debug mode) reaches the device
// without a re-login. Best-effort; failures keep the cached value.
scope.launch { refreshProfile() }
}
@@ -35,13 +35,13 @@ import javax.inject.Singleton
* sees the new value immediately; the DAO write coroutine catches up
* shortly after.
*
* **The session cookie is the exception** (M462 #4985): it is persisted
* through [SessionVault] (Keystore-encrypted), not the Room row. On the
* first launch after the upgrade, a cookie still in the row is moved into
* the vault and the column cleared, so nobody is signed out by the change.
* If the Keystore cannot be used on a device, the cookie stays in the row
* as before rather than being lost. [awaitSessionHydrated] lets a caller
* that needs a definitive answer (the auth gate) wait for this.
* **The session cookie is the exception**: it is persisted through
* [SessionVault] (Keystore-encrypted), not the Room row. A cookie still
* in the row is moved into the vault on launch and the column cleared,
* so nobody is signed out by the move. If the Keystore cannot be used
* on a device, the cookie stays in the row rather than being lost.
* [awaitSessionHydrated] lets a caller that needs a definitive answer
* (the auth gate) wait for this.
*/
// AuthStore is the single-row facade over auth_session (de-facto
// app_preferences — see entity comment). It legitimately owns one
@@ -81,7 +81,7 @@ class AuthStore @Inject constructor(
private val normalizationState = MutableStateFlow(NormalizationPrefs.DEFAULT)
val normalization: StateFlow<NormalizationPrefs> = normalizationState.asStateFlow()
// Background delivery (M489 #5347): the device's choice, on by default.
// Background delivery: the device's choice, on by default.
// The shade's high-water mark lives in the same row but is read and
// written through the DAO by NotificationSync, awaited, never cached here.
private val backgroundDeliveryState = MutableStateFlow(true)
@@ -131,8 +131,8 @@ class AuthStore @Inject constructor(
/**
* Suspends until the stored session cookie has been loaded into
* [sessionCookie], or [HYDRATION_DEADLINE_MS] passes (rule 156: a wedged
* Keystore must not leave the start screen spinning). Returns false on
* [sessionCookie], or [HYDRATION_DEADLINE_MS] passes, so a wedged
* Keystore cannot leave the start screen spinning. Returns false on
* the deadline; the caller then decides from whatever has loaded, and a
* late hydration still lands in [sessionCookie].
*/
@@ -19,15 +19,14 @@ import javax.inject.Inject
import javax.inject.Singleton
/**
* Where the session cookie lives at rest (M462 #4985).
* Where the session cookie lives at rest.
*
* The cookie is a bearer credential: anyone holding it is signed in as the
* user until the server expires or revokes it. It used to sit in plain text
* in the Room `auth_session` row, readable from any copy of the app's data
* directory (a rooted device, an adb backup of a debuggable build, a
* forensic image). Now only ciphertext is stored, under an AES key that
* lives in the Android Keystore and never leaves it, so a copy of the
* files alone yields nothing usable.
* user until the server expires or revokes it. In plain text it would be
* readable from any copy of the app's data directory (a rooted device, an
* adb backup of a debuggable build, a forensic image). Only ciphertext is
* stored, under an AES key that lives in the Android Keystore and never
* leaves it, so a copy of the files alone yields nothing usable.
*/
interface SessionVault {
/** The stored cookie, or null when none is stored or it can't be decrypted. */
@@ -19,8 +19,8 @@ import javax.inject.Inject
* persisted auth state. Reads the row from AuthSessionDao directly, and
* the session cookie only after [AuthStore.awaitSessionHydrated]: both
* StateFlows default to null until their async load lands, and we need a
* definitive answer before drawing any nav graph. The cookie is no longer
* in the row (it lives in the Keystore-backed SessionVault, #4985).
* definitive answer before drawing any nav graph. The cookie lives in the
* Keystore-backed SessionVault, not the row.
*
* - no row at all → ServerUrl (first launch)
* - row with baseUrl, no cookie → Login (URL configured, not yet signed in)
@@ -18,15 +18,13 @@ import javax.inject.Singleton
* Populates `audio_cache_index` from playback so the offline pools
* ([ShuffleSource]) and the cached indicator have a data source.
*
* Mirrors the user-visible behavior of Flutter's `AudioCacheManager`
* (`registerStreamCache` + `touch`): a track you play gets cached as a
* side effect of streaming, and playing it bumps its recency. The
* Android idiom differs intentionally — Media3's `SimpleCache` is the
* real byte store (span-based, keyed by trackId via the MediaItem's
* custom cache key), so this index is a lightweight *play-recency*
* record (source = INCIDENTAL) rather than Flutter's file-per-track
* table. SimpleCache stays the source of truth for "is it actually on
* disk"; this index orders "recently played" for the offline pools.
* A track you play gets cached as a side effect of streaming, and
* playing it bumps its recency. Media3's `SimpleCache` is the real byte
* store (span-based, keyed by trackId via the MediaItem's custom cache
* key), so this index is a lightweight *play-recency* record
* (source = INCIDENTAL), not a file-per-track table. SimpleCache stays
* the source of truth for "is it actually on disk"; this index orders
* "recently played" for the offline pools.
*
* State observed against [PlayerController.uiState], deduped by track
* id so each entry into the player records once. Eager-constructed via
@@ -19,11 +19,10 @@ private const val POOL_LIMIT = 100
* storage/eviction-only and never filters playback — exactly what
* this relies on.
*
* Divergence note: Flutter's `audioCacheIndex` only holds fully-cached
* tracks, so its pools are inherently resident. Android's index records
* plays (see `CacheIndexer`), so the recency list is intersected with
* true Media3 SimpleCache residency here — the pool never offers a
* played-then-evicted track that would fail to play offline.
* The index records plays (see `CacheIndexer`), not residency, so the
* recency list is intersected with true Media3 SimpleCache residency
* here — the pool never offers a played-then-evicted track that would
* fail to play offline.
*
* Surfaced on Home's Playlists row only when offline; tap shuffles +
* plays the pool from the cache.
@@ -58,7 +57,7 @@ class ShuffleSource @Inject constructor(
if (orderedIds.isEmpty()) return emptyList()
val byId = trackDao.getByIds(orderedIds).associateBy { it.id }
return orderedIds.mapNotNull { id ->
// Clear the server's missing mark (#2704). Every id reaching here
// Clear the server's missing mark. Every id reaching here
// came through residentIdsByRecency, which already proved the
// AUDIO is in the local cache — so these play regardless of what
// the server has lost, and the queue filter in PlayerController
@@ -1,16 +1,16 @@
package com.fabledsword.minstrel.cache.audiocache
/**
* Defaults for the 2-bucket audio cache.
* Defaults for the audio cache; the user's Storage settings override them.
*
* - `likedCapBytes`: cap for the protected bucket — cached files for
* tracks the user has liked. Evicted only after the rolling bucket
* is fully drained (almost never under normal use).
* - `rollingCapBytes`: cap for the unprotected bucket — incidental /
* auto-prefetch downloads. LRU within this bucket.
* - `likedCapBytes`: cap meant for a protected bucket of liked tracks'
* files. Nothing enforces it yet; SimpleCache evicts by the rolling
* cap alone.
* - `rollingCapBytes`: cap for incidental / auto-prefetch downloads,
* evicted least-recently-used first.
*
* Phase 11 (Settings) will allow user overrides; the values are kept
* generous so first-time installs work well without configuration.
* The values are generous so first-time installs work well without
* configuration.
*/
data class CacheConfig(
val likedCapBytes: Long = DEFAULT_LIKED_CAP_BYTES,
@@ -10,18 +10,18 @@ private const val DEFAULT_PREFETCH_WINDOW = 5
* blob on the auth_session single-row table via [AuthStore].
*
* - [likedCapBytes]: budget for cached files of liked tracks. 0 means
* unlimited. Effective at next app start (SimpleCache is built once
* per process).
* unlimited. Nothing enforces it yet: SimpleCache evicts by
* [rollingCapBytes] alone, and the Storage card hides this control.
* - [rollingCapBytes]: budget for incidental / auto-prefetch downloads.
* Same 0=unlimited / restart-to-apply caveat.
* - [prefetchWindow]: 1..10. Number of next-tracks the prefetcher
* pre-downloads. Has no effect until the prefetcher lands (separate
* audit follow-up) — persisted now so the user can pre-configure.
* 0 means unlimited. Effective at next app start (SimpleCache is
* built once per process).
* - [prefetchWindow]: 1..10. Number of next-tracks `AudioPrefetcher`
* pre-downloads.
* - [cacheLikedTracks]: when true, liking a track should also pin its
* audio to the liked-cache bucket. Has no effect until pin-on-like
* lands (separate follow-up).
* audio to the liked-cache bucket. Nothing reads it yet, and the
* Storage card hides this control.
*
* Defaults match Flutter: 5 GiB per bucket, prefetch=5, cache-liked=true.
* Defaults: 5 GiB per bucket, prefetch=5, cache-liked=true.
*/
@Serializable
data class CacheSettings(
@@ -40,17 +40,12 @@ import com.fabledsword.minstrel.cache.db.entities.DiagnosticEventEntity
import com.fabledsword.minstrel.cache.db.entities.SyncMetadataEntity
/**
* Single Room database for the whole app. Mirrors the Flutter client's
* Drift `AppDb` — same logical schema, one entity-family per @Entity.
* Single Room database for the whole app, one entity family per @Entity.
*
* Native v1 starts at schema version 1 (the Flutter Drift schemaVersion 11
* is an internal client detail; the server is the source of truth and the
* sync controller refills on first launch). Future schema bumps land per
* change with explicit `Migration` entries; `fallbackToDestructiveMigration`
* in `DatabaseModule` is the safety net while we're pre-v1.
*
* Entity families land in Task 4.2 slices. The set will grow until the
* whole Drift schema is mirrored.
* The server is the source of truth: the sync controller refills the
* cache on first launch. Schema bumps land with explicit `Migration`
* entries; `fallbackToDestructiveMigration` in `DatabaseModule` rebuilds
* the database from a version that has no migration path.
*/
@Database(
entities = [
@@ -73,25 +68,25 @@ import com.fabledsword.minstrel.cache.db.entities.SyncMetadataEntity
CachedNotificationSettingsEntity::class,
],
// v12: + auth_session.backgroundDelivery and notifiedUpTo, the device's
// background-delivery choice and its shade high-water mark (M489 #5347).
// background-delivery choice and its shade high-water mark.
// v11: + cached_notifications and cached_notification_settings, the
// notifications inbox and its settings (M489). MIGRATION_10_11 creates
// notifications inbox and its settings. MIGRATION_10_11 creates
// both; nothing to backfill, the first refresh fills them.
// v10: + cached_tracks.trackGain/trackPeak and cached_albums.albumGain/
// albumPeak, the ReplayGain values the player levels by (M464 #5000).
// albumPeak, the ReplayGain values the player levels by.
// MIGRATION_9_10 also rewinds the sync cursor, so the next sync re-sends
// every row and an existing cache gains its values.
// v9: + auth_session.normalizationJson, the loudness-normalization
// preference (M464 #4998). The first schema step with an explicit
// Migration (MIGRATION_8_9): a destructive rebuild would also wipe this
// row — the server address and theme — and the queued offline writes,
// preference. The first schema step with an explicit Migration
// (MIGRATION_8_9): a destructive rebuild would also wipe this row —
// the server address and theme — and the queued offline writes,
// which is too much to lose for one added column.
// v8: + cached_tracks.missing, the server's missing-file mark (#2704),
// v8: + cached_tracks.missing, the server's missing-file mark,
// so cache-first surfaces stop offering files that cannot stream.
// v7: + diagnostic_events table (M9) and the diagnosticsOptOut column
// on auth_session. Pre-v1 destructive fallback rebuilds on mismatch —
// which is exactly right here: the next sync refills every row with the
// new column populated, so there is nothing to migrate by hand.
// v7: + diagnostic_events table and the diagnosticsOptOut column
// on auth_session. Versions before v8 rebuild destructively, which is
// right for them: the next sync refills every row with the new
// columns populated, so there is nothing to migrate by hand.
version = 12,
exportSchema = true,
)
@@ -115,7 +110,7 @@ abstract class AppDatabase : RoomDatabase() {
abstract fun cachedNotificationDao(): CachedNotificationDao
}
/** v8 → v9: add the nullable normalization preference column (#4998). */
/** v8 → v9: add the nullable normalization preference column. */
val MIGRATION_8_9: Migration = object : Migration(8, 9) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("ALTER TABLE auth_session ADD COLUMN normalizationJson TEXT")
@@ -123,7 +118,7 @@ val MIGRATION_8_9: Migration = object : Migration(8, 9) {
}
/**
* v9 → v10: the gain columns (#5000). Rows synced before this carry no gains,
* v9 → v10: the gain columns. Rows synced before this carry no gains,
* and the sync is incremental, so it would never re-send them: the cursor goes
* back to 0 and the next sync is a full one, upserting every row in place.
*/
@@ -138,7 +133,7 @@ val MIGRATION_9_10: Migration = object : Migration(9, 10) {
}
/**
* v10 → v11: the notifications inbox cache and its settings (M489). The SQL
* v10 → v11: the notifications inbox cache and its settings. The SQL
* matches what Room generates for the two entities; Room checks it on open.
*/
val MIGRATION_10_11: Migration = object : Migration(10, 11) {
@@ -157,7 +152,7 @@ val MIGRATION_10_11: Migration = object : Migration(10, 11) {
}
/**
* v11 → v12: background delivery (M489 #5347). The device choice defaults on,
* v11 → v12: background delivery. The device choice defaults on,
* as the entity's column default says; the high-water mark starts empty, so
* the first catch-up sets it without announcing anything.
*/
@@ -33,11 +33,10 @@ object DatabaseModule {
@Singleton
fun provideDatabase(@ApplicationContext context: Context): AppDatabase =
Room.databaseBuilder(context, AppDatabase::class.java, DATABASE_NAME)
// Pre-v1 safety net: rebuild on any schema mismatch. The sync
// controller refills the cache from the server on first
// launch, so users lose only the unsynced mutation queue
// (acceptable while we're iterating). Replace with explicit
// Migration entries before the first tagged release.
// Explicit migrations from v8 onward; an older schema has no path
// and rebuilds. The sync controller refills the cache from the
// server on first launch, so a rebuild loses only the unsynced
// mutation queue.
.addMigrations(MIGRATION_8_9, MIGRATION_9_10, MIGRATION_10_11, MIGRATION_11_12)
.fallbackToDestructiveMigration(dropAllTables = true)
.build()
@@ -4,8 +4,7 @@ import androidx.room.TypeConverter
import kotlinx.datetime.Instant
/**
* Source category for a cached audio file. Mirrors the Drift `CacheSource`
* enum used by the Flutter client's `audio_cache_index` table 1:1.
* Source category for a cached audio file in `audio_cache_index`.
*
* - `MANUAL`: user explicitly pinned (e.g. "save for offline")
* - `AUTO_LIKED`: auto-cached because the track is liked
@@ -13,8 +12,10 @@ import kotlinx.datetime.Instant
* - `AUTO_PREFETCH`: pre-warmed by background prefetch heuristics
* - `INCIDENTAL`: cached as a side effect of playback streaming
*
* Eviction priority order (first to evict): INCIDENTAL → AUTO_PREFETCH
* → AUTO_PLAYLIST → AUTO_LIKED → MANUAL.
* Eviction priority (first to evict): INCIDENTAL → AUTO_PREFETCH
* → AUTO_PLAYLIST → AUTO_LIKED → MANUAL. Only INCIDENTAL is written
* today, and SimpleCache evicts by plain LRU, so nothing applies the
* order yet.
*/
enum class CacheSource { MANUAL, AUTO_LIKED, AUTO_PLAYLIST, AUTO_PREFETCH, INCIDENTAL }
@@ -22,10 +23,8 @@ enum class CacheSource { MANUAL, AUTO_LIKED, AUTO_PLAYLIST, AUTO_PREFETCH, INCID
* Cross-cutting Room type converters. Registered on `@Database` via
* `@TypeConverters(MinstrelTypeConverters::class)`.
*
* `kotlinx.datetime.Instant` ↔ `Long` epoch millis: the same convention the
* Flutter client uses (Drift `text()` column with ISO-8601 strings would
* also work but Long is leaner; we don't need human-readable rows in the
* SQLite file).
* `kotlinx.datetime.Instant` ↔ `Long` epoch millis: leaner than ISO-8601
* text, and nothing needs human-readable rows in the SQLite file.
*/
class MinstrelTypeConverters {
@TypeConverter fun instantToLong(i: Instant?): Long? = i?.toEpochMilliseconds()
@@ -44,10 +44,9 @@ interface CachedLikeDao {
/**
* Atomically replaces the user's entire cached_likes set with
* [rows]. Used by [com.fabledsword.minstrel.likes.data.LikesRepository.refreshIds]
* so cross-device unlikes (a row that the server no longer
* surfaces) get removed from the local cache — drift #570
* caught the missing delete pass that left stale Liked tab
* entries pointing at tracks the user had unliked elsewhere.
* so an unlike made on another device (a row the server no longer
* surfaces) leaves the local cache too; upserts alone would keep a
* stale Liked tab entry forever.
*/
@Transaction
suspend fun replaceAllForUser(userId: String, rows: List<CachedLikeEntity>) {
@@ -46,13 +46,13 @@ interface CachedPlaylistTrackDao {
* Atomic on purpose. Room's InvalidationTracker only notifies observers
* after the transaction commits, so [observeByPlaylist] never sees the
* empty gap between the delete and the re-insert. Un-transacted, that
* gap is a real observed state — it's what made every Home row visibly
* collapse to empty and refill before issue #2327 fixed the equivalent
* write in `CachedHomeIndexDao`.
* gap is a real observed state: the list visibly collapses to empty
* and refills, which is why `CachedHomeIndexDao` transacts its writes
* too.
*
* Nothing observes [observeByPlaylist] live today, so this is
* pre-emptive: it means making playlist detail cache-first later can't
* silently reintroduce that flicker.
* pre-emptive: making playlist detail cache-first later can't
* silently bring that flicker in.
*/
@Transaction
suspend fun replacePlaylistTracks(
@@ -41,8 +41,7 @@ interface CachedQuarantineDao {
/**
* Atomic full-replace from the server snapshot — one transaction so
* the `observeAll()` watcher sees a single merged emission, not a
* delete-then-insert flicker. Mirrors Flutter's MyQuarantineController
* `_refreshFromServer` transaction.
* delete-then-insert flicker.
*/
@Transaction
suspend fun replaceAll(rows: List<CachedQuarantineEntity>) {
@@ -39,7 +39,7 @@ interface CachedTrackDao {
suspend fun upsertAll(rows: List<CachedTrackEntity>)
/**
* ReplayGain values for [ids] (M464 #5000): the track's own from its row,
* ReplayGain values for [ids]: the track's own from its row,
* the album's from its album row. A track not in the cache has no row.
*/
@Query(
@@ -7,14 +7,12 @@ import kotlinx.datetime.Clock
import kotlinx.datetime.Instant
/**
* One row per fully-downloaded audio file. Mirrors
* the Flutter client's `AudioCacheIndex` Drift table.
*
* Drives the 2-bucket LRU eviction (Phase 12 AudioCacheEvictionWorker):
* - `incidental` files (streamed-and-cached side effect) evict first
* - `autoPrefetch` second
* - `manual` (user explicitly pinned) evict last
* One row per track that has played through the audio cache (see
* `CacheIndexer`); SimpleCache, not this table, says what is on disk.
*
* The `source` column is meant to drive a 2-bucket eviction
* (incidental first, then auto-prefetch, manual last). Nothing evicts
* by it yet: SimpleCache evicts by plain LRU over the rolling cap.
* `cachedAt` = when we downloaded. `lastPlayedAt` = when the user last
* played it; drives the offline "recently played" ordering and is the
* tiebreaker for LRU eviction within a source bucket. Nullable on
@@ -39,7 +39,7 @@ data class AuthSessionEntity(
*/
val cacheSettingsJson: String? = null,
/**
* Per-device opt-out of diagnostics reporting (M9). When the
* Per-device opt-out of diagnostics reporting. When the
* account's debug mode is enabled by an admin, the user can still
* turn reporting OFF on this device (battery/privacy) — that local
* choice lives here. Default false = honor the account flag.
@@ -47,12 +47,12 @@ data class AuthSessionEntity(
val diagnosticsOptOut: Boolean = false,
/**
* JSON-encoded NormalizationPrefs (settings/data), the last value seen
* from the server or set here (M464 #4998). Null = never fetched; the
* from the server or set here. Null = never fetched; the
* defaults apply. Kept so offline playback still levels.
*/
val normalizationJson: String? = null,
/**
* "Notifications when the app is closed" (M489 #5347): keep the
* "Notifications when the app is closed": keep the
* delivery foreground service running. A device choice, on by default.
* The column default matches MIGRATION_11_12's, which Room checks.
*/
@@ -5,10 +5,7 @@ import androidx.room.PrimaryKey
import kotlinx.datetime.Clock
import kotlinx.datetime.Instant
/**
* Cache row for one album. Mirrors the Flutter client's
* `CachedAlbums` Drift table.
*/
/** Cache row for one album. */
@Entity(tableName = "cached_albums")
data class CachedAlbumEntity(
@PrimaryKey val id: String,
@@ -18,7 +15,7 @@ data class CachedAlbumEntity(
val releaseDate: String? = null,
val coverPath: String? = null,
val mbid: String? = null,
// ReplayGain 2.0 album values (M464); null until every track is measured.
// ReplayGain 2.0 album values; null until every track is measured.
val albumGain: Float? = null,
val albumPeak: Float? = null,
val fetchedAt: Instant = Clock.System.now(),
@@ -6,13 +6,11 @@ import kotlinx.datetime.Clock
import kotlinx.datetime.Instant
/**
* Cache row for one artist. Mirrors the Flutter client's
* `CachedArtists` Drift table.
* Cache row for one artist.
*
* Column names follow Kotlin idiom (camelCase) rather than Drift's
* snake_case — the schema is purely internal to the native client; the
* sync controller exchanges snake_case JSON with the server and our
* mappers translate at the boundary.
* Column names follow Kotlin idiom (camelCase) — the schema is purely
* internal to the app; the sync controller exchanges snake_case JSON
* with the server and our mappers translate at the boundary.
*/
@Entity(tableName = "cached_artists")
data class CachedArtistEntity(
@@ -8,8 +8,7 @@ import kotlinx.datetime.Instant
/**
* Single-row snapshot of the `/api/me/history` response, stored as the
* raw wire JSON so the History tab paints instantly from disk on cold
* open and survives connectivity loss. Mirrors the Flutter Drift
* `CachedHistorySnapshot` table (id defaults to 1; one row total).
* open and survives connectivity loss. One row total; id defaults to 1.
*
* Cache-first + SWR: the tab observes this row and a background refresh
* overwrites it, so the freshest plays land without a blocking spinner.
@@ -5,8 +5,7 @@ import kotlinx.datetime.Clock
import kotlinx.datetime.Instant
/**
* Per-item row driving the Home screen sections. Mirrors
* the Flutter client's `CachedHomeIndex` Drift table.
* Per-item row driving the Home screen sections.
*
* `section` is one of (matching /api/home keys):
* - "recently_added_albums"
@@ -5,14 +5,13 @@ import kotlinx.datetime.Clock
import kotlinx.datetime.Instant
/**
* Like membership row. Mirrors the Flutter client's
* `CachedLikes` Drift table. Composite primary key — one user may
* Like membership row. Composite primary key — one user may
* independently like a track AND its album AND its artist; rows are
* disambiguated by the (userId, entityType, entityId) triple.
*
* `entityType` is one of "track" | "album" | "artist". Stored as a
* plain string for parity with the Drift schema and the server's wire
* format; an enum doesn't earn its keep here.
* plain string to match the server's wire format; an enum doesn't
* earn its keep here.
*/
@Entity(
tableName = "cached_likes",
@@ -6,12 +6,11 @@ import kotlinx.datetime.Clock
import kotlinx.datetime.Instant
/**
* One row per pending offline-write. Mirrors
* the Flutter client's `CachedMutations` Drift table.
* One row per pending offline write.
*
* MutationQueue.enqueue() inserts a row when a server-write fails with
* an IOException; MutationReplayer.drain() pops and re-attempts each
* row when connectivity returns (Phase 12.2).
* row when connectivity returns.
*
* `kind` is a stable string registered in `MutationKind` — the replayer
* looks up the handler in a kind→suspend-function map. Unknown kinds
@@ -5,7 +5,7 @@ import androidx.room.PrimaryKey
import kotlinx.datetime.Instant
/**
* One notice from the user's inbox (M489), kept so the Notifications screen
* One notice from the user's inbox, kept so the Notifications screen
* and the bell's badge work offline, and so a read made offline shows at once.
* The newest page is cached; older notices are the server's to keep.
*/
@@ -4,7 +4,7 @@ import androidx.room.Entity
import androidx.room.PrimaryKey
/**
* Single-row copy of the user's notification settings (M489), stored as the
* Single-row copy of the user's notification settings, stored as the
* wire JSON, so the settings screen opens offline and a toggle shows at once.
*/
@Entity(tableName = "cached_notification_settings")
@@ -6,8 +6,7 @@ import kotlinx.datetime.Clock
import kotlinx.datetime.Instant
/**
* Cache row for one playlist (user or system). Mirrors
* the Flutter client's `CachedPlaylists` Drift table.
* Cache row for one playlist (user or system).
*
* `systemVariant` is null for user playlists and one of
* "for_you" / "songs_like_artist" / "discover" / "todays_mix" / etc.
@@ -3,8 +3,7 @@ package com.fabledsword.minstrel.cache.db.entities
import androidx.room.Entity
/**
* Ordered membership of tracks within a playlist. Mirrors
* the Flutter client's `CachedPlaylistTracks` Drift table.
* Ordered membership of tracks within a playlist.
* Composite PK so the same track can only appear once per playlist;
* `position` carries the ordering.
*/
@@ -6,9 +6,7 @@ import kotlinx.datetime.Clock
import kotlinx.datetime.Instant
/**
* The current user's quarantine flag for one track. Mirrors
* the Flutter client's `CachedQuarantineMine` Drift
* table.
* The current user's quarantine flag for one track.
*
* The flat denormalized track/album/artist columns let the Quarantine
* screen render off this single table without a join — the server
@@ -7,18 +7,15 @@ import kotlinx.datetime.Instant
/**
* Single-row snapshot of the last playback session — queue (as JSON),
* current index, position, and source tag. Mirrors
* the Flutter client's `CachedResumeState` Drift table.
* current index, position, and source tag.
*
* Lets a torn-down session (the player's idle/dismissed teardown)
* resume on next launch; without it the headset / lock-screen play
* button has nothing to act on. The Flutter codebase persists this on
* every queue change and reads on cold start.
* button has nothing to act on.
*
* The `json` field carries the entire ResumeState shape (queue trackrefs,
* currentIndex, positionMs, sourceTag). Kotlin-side deserialization
* happens in the ResumeController (Phase 6.5) so the schema stays
* stable across resume-shape changes.
* currentIndex, positionMs, sourceTag). The ResumeController decodes it,
* so the schema stays stable across resume-shape changes.
*/
@Entity(tableName = "cached_resume_state")
data class CachedResumeStateEntity(
@@ -6,10 +6,9 @@ import kotlinx.datetime.Clock
import kotlinx.datetime.Instant
/**
* Cache row for one track. Mirrors the Flutter client's
* `CachedTracks` Drift table.
* Cache row for one track.
*
* [missing] carries the server's missing-file mark (#2704). Every read that
* [missing] carries the server's missing-file mark. Every read that
* can put a track in front of the user — or in a queue — must exclude it, and
* the DAO queries do that rather than each call site remembering to.
*/
@@ -26,7 +25,7 @@ data class CachedTrackEntity(
val fileFormat: String? = null,
val genre: String? = null,
val missing: Boolean = false,
// ReplayGain 2.0 track values (M464), kept so cached audio levels
// ReplayGain 2.0 track values, kept so cached audio levels
// offline. Null until the server has measured the track.
val trackGain: Float? = null,
val trackPeak: Float? = null,
@@ -4,14 +4,14 @@ import androidx.room.Entity
import androidx.room.PrimaryKey
/**
* One buffered device-diagnostics event (M9). The DiagnosticsReporter
* One buffered device-diagnostics event. The DiagnosticsReporter
* writes rows here when the account's debug mode is on; the
* DiagnosticsUploader drains them to POST /api/diagnostics and deletes
* on success.
*
* This is a deliberate ring buffer that is NOT routed through the offline
* MutationQueue: diagnostics are high-volume, best-effort telemetry, and
* the bug we're chasing (roaming / dead-zone recovery) happens WHILE
* the failures they record (roaming / dead-zone recovery) happen WHILE
* offline — so events must persist locally through the dead zone and
* upload on recovery, without clogging the user-data mutation replay.
*
@@ -7,12 +7,8 @@ import kotlinx.datetime.Instant
/**
* Single-row table that tracks where the SyncController left off.
*
* Mirrors the Flutter Drift `sync_metadata` table. There is only ever
* one row at `id = 0`; the SyncController upserts on every successful
* `/api/sync` call.
*
* Pulled into Task 4.1 to give Room a compileable @Database (it needs
* at least one entity); the SyncController itself lands in Phase 12.4.
* There is only ever one row at `id = 0`; the SyncController upserts it
* on every successful `/api/library/sync` call.
*/
@Entity(tableName = "sync_metadata")
data class SyncMetadataEntity(
@@ -33,30 +33,29 @@ object MutationKind {
// Durable close-by-id for a play whose server row is already open
// (play_started succeeded). Distinct from PLAY_OFFLINE, which records
// a *whole* play when no server row exists. Using close-by-id on
// background avoids the duplicate + orphan row the old offline-on-stop
// path produced (see 2026-06-11 contract audit).
// a *whole* play when no server row exists. Recording a whole play on
// background would duplicate the open row and leave it orphaned.
const val PLAY_ENDED: String = "play_ended"
// #2374 suggestion snooze. ONE toggle kind rather than separate
// Suggestion snooze. ONE toggle kind rather than separate
// snooze/unsnooze kinds, mirroring LIKE_TOGGLE, so a snooze followed by
// an undo collapses to the latest intent instead of replaying as two
// opposed calls whose order decides the outcome.
const val SUGGESTION_SNOOZE_TOGGLE: String = "suggestion_snooze_toggle"
// M464 #4998 loudness-normalization preference. The payload is the whole
// Loudness-normalization preference. The payload is the whole
// preference, a target state like the toggles above, so queued changes
// collapse to the last one and an older one can never be replayed last.
const val NORMALIZATION_SET: String = "normalization_set"
// M489 notifications inbox. A read is a one-way action (read never goes
// Notifications inbox. A read is a one-way action (read never goes
// back to unread), so neither read kind needs collapsing. Read-all
// carries the moment the user asked, so a late replay leaves newer
// notices unread.
const val NOTIFICATION_READ: String = "notification_read"
const val NOTIFICATIONS_READ_ALL: String = "notifications_read_all"
// M489 per-kind channel setting. One row per (kind, channel) target
// Per-kind notification channel. One row per (kind, channel) target
// state, collapsed on that pair, so the newest choice is the one sent.
const val NOTIFICATION_SETTING_SET: String = "notification_setting_set"
}
@@ -92,16 +91,13 @@ data class RequestCreatePayload(
)
/**
* Minimal write-side of the offline mutation queue. Phase 8 v1 only
* enqueues — the connectivity-aware replayer that drains the queue
* lands with Phase 12.2's SyncController.
* Write side of the offline mutation queue; [MutationReplayer] drains it.
*
* Callers (LikesRepository et al.) follow the optimistic-write
* pattern: mutate Room first so the UI reflects the change
* immediately, fire the REST call as a best-effort attempt, and on
* failure enqueue here so the replayer eventually pushes it through.
* This matches `feedback_offline_first_for_server_writes` — writes
* never go fire-and-forget.
* Writes never go fire-and-forget.
*/
@Suppress("TooManyFunctions") // one enqueue per mutation kind, like the replayer's dispatchers
@Singleton
@@ -262,7 +258,7 @@ class MutationQueue @Inject constructor(
}
/**
* Persisted payload for `MutationKind.SUGGESTION_SNOOZE_TOGGLE` (#2374).
* Persisted payload for `MutationKind.SUGGESTION_SNOOZE_TOGGLE`.
* `desiredSnoozed` is the *target* state, matching [LikeTogglePayload], so
* the replayer can collapse repeated toggles for one candidate down to the
* last intent. Both directions are idempotent server-side: re-snoozing
@@ -285,10 +281,10 @@ data class SuggestionSnoozeTogglePayload(
data class QuarantineUnflagPayload(val trackId: String)
/**
* Persisted payload for `MutationKind.PLAYLIST_APPEND`. Mirrors the
* Flutter wire shape: list of track IDs to append to a specific
* playlist. Server is idempotent at the (playlist_id, track_id) pair
* level — re-firing the same payload yields no duplicates.
* Persisted payload for `MutationKind.PLAYLIST_APPEND`: the track IDs to
* append to one playlist. The server is idempotent at the
* (playlist_id, track_id) pair level — re-firing the same payload yields
* no duplicates.
*/
@Serializable
data class PlaylistAppendPayload(
@@ -314,7 +310,7 @@ data class QuarantineFlagPayload(
* re-fires `POST /api/events` with type=play_offline; the server
* records start+end from `atIso` (the original play-start time)
* + `durationPlayedMs`, applies the canonical skip rule, and
* advances #415 rotation when source is a system playlist.
* advances rotation when source is a system playlist.
*/
@Serializable
data class PlayOfflinePayload(
@@ -323,7 +319,7 @@ data class PlayOfflinePayload(
val atIso: String,
val durationPlayedMs: Long,
val source: String? = null,
// #1551: device class for context conditioning; null on payloads queued
// Device class for context conditioning; null on payloads queued
// before this field existed (decodes to null → server stores NULL).
val deviceClass: String? = null,
)
@@ -367,12 +363,12 @@ data class PlaybackErrorReportPayload(
val clientId: String,
)
/** Persisted payload for `MutationKind.NOTIFICATION_READ` (M489). */
/** Persisted payload for `MutationKind.NOTIFICATION_READ`. */
@Serializable
data class NotificationReadPayload(val id: String)
/**
* Persisted payload for `MutationKind.NOTIFICATIONS_READ_ALL` (M489).
* Persisted payload for `MutationKind.NOTIFICATIONS_READ_ALL`.
* `upToIso` is the newest notice the user could see when they asked; null
* when the inbox was empty on the device, which marks everything.
*/
@@ -380,7 +376,7 @@ data class NotificationReadPayload(val id: String)
data class NotificationsReadAllPayload(val upToIso: String?)
/**
* Persisted payload for `MutationKind.NOTIFICATION_SETTING_SET` (M489): one
* Persisted payload for `MutationKind.NOTIFICATION_SETTING_SET`: one
* kind's one channel, as a target state. `channel` is "inbox" | "phone" |
* "email". No defaults, so every field is always written.
*/
@@ -160,7 +160,7 @@ class MutationReplayer @Inject constructor(
/**
* Returns the outcome of replaying one row. SENT on a 2xx; DROP for an
* unknown kind/variant. Network / HTTP failures THROW and are classified
* by [outcomeFor] — dispatchers no longer swallow.
* by [outcomeFor] — dispatchers never swallow them.
*/
private suspend fun dispatch(row: CachedMutationEntity): Outcome = when (row.kind) {
MutationKind.LIKE_TOGGLE -> dispatchLikeToggle(row.payload)
@@ -182,7 +182,7 @@ class MutationReplayer @Inject constructor(
else -> Outcome.DROP
}
/** The notifications inbox's kinds (M489), split out to keep [dispatch] simple. */
/** The notifications inbox's kinds, split out to keep [dispatch] simple. */
private suspend fun dispatchNotification(row: CachedMutationEntity): Outcome = when (row.kind) {
MutationKind.NOTIFICATION_READ -> dispatchNotificationRead(row.payload)
MutationKind.NOTIFICATIONS_READ_ALL -> dispatchNotificationsReadAll(row.payload)
@@ -354,8 +354,8 @@ class MutationReplayer @Inject constructor(
/**
* Row ids of desired-state toggles superseded by a later toggle for the same
* entity. Applies to every kind whose payload encodes a TARGET state rather
* than an action — like-toggles, suggestion snoozes (#2374) and the
* normalization preference (#4998) — because
* than an action — like-toggles, suggestion snoozes and the
* normalization preference — because
* replaying a stale one last would invert the final state.
*
* Top-level and pure so it can be unit-tested without standing up a Retrofit
@@ -34,9 +34,8 @@ import javax.inject.Inject
import javax.inject.Singleton
/**
* Drives the delta sync against `/api/library/sync` (#357). Mirrors
* the Flutter `SyncController`, scoped to the entity types the
* native client cares about (artist/album/track). Likes and
* Drives the delta sync against `/api/library/sync`, scoped to the
* entity types the app caches (artist/album/track). Likes and
* playlists get their own dedicated refresh paths so the sync
* controller doesn't need to track them.
*
@@ -46,10 +45,9 @@ import javax.inject.Singleton
* with a persisted cookie). Re-running on every cookie change is
* what makes the Library tabs populate within seconds of sign-in.
*
* Wipes-on-410: when the server's compaction window has passed the
* stored cursor, the response is 410 Gone. The controller drops the
* three local caches and recurses with cursor=0 — same wipe behavior
* as the Flutter port.
* Restart-on-410: when the server's compaction window has passed the
* stored cursor, the response is 410 Gone. The controller resets the
* cursor to 0 and pulls the full entity set again (see [resetCursor]).
*/
@Singleton
class SyncController @Inject constructor(
@@ -110,11 +108,11 @@ class SyncController @Inject constructor(
}
/**
* Pulls pages until the server has nothing newer (#5654). The server
* returns at most 1000 changes a call; taking one page per trigger left
* the cache behind a change log that a full re-read grows by ~26,000
* rows, so merge deletes never arrived and the freshness sweep kept
* asking for tracks that were gone. The cursor is stored after each
* Pulls pages until the server has nothing newer. The server returns
* at most 1000 changes a call, and a full library re-read adds tens
* of thousands, so one page per trigger would leave the cache behind:
* merge deletes would never arrive and the freshness sweep would keep
* asking for tracks that are gone. The cursor is stored after each
* page, so an interrupted pass resumes where it stopped.
*/
private suspend fun sync() {
@@ -153,7 +151,7 @@ class SyncController @Inject constructor(
body.cursor > cursor
}
else -> {
// Unhandled status — treat as a no-op for v1; the next
// Unhandled status — treat as a no-op; the next
// app start retries from the same cursor.
false
}
@@ -29,27 +29,25 @@ private const val POLL_DEGRADED_MS = 20_000L
private const val ARBITRATE_MIN_GAP_MS = 2_000L
/**
* THE single authority on network/server reachability. Absorbs the former
* VersionCheckController (the /healthz poll + version parsing) and
* ServerHealthController (the tri-state derive) into one signal-driven unit so
* the app has one place that answers "can we reach Minstrel?" — not three
* half-systems reporting differently.
* THE single authority on network/server reachability: the /healthz poll,
* version parsing and the tri-state health derive live in one
* signal-driven unit, so the app has one place that answers "can we reach
* Minstrel?".
*
* Inputs (all funnel through a single-consumer intent channel for thread
* safety — [reportSuccess]/[reportFailure] are called from the audio read path
* and OkHttp threads):
* - device link transitions ([ConnectivityObserver]); link-return triggers an
* immediate probe so recovery is near-instant (fixes the sticky banner).
* immediate probe so recovery is near-instant.
* - periodic /healthz probe, adaptive cadence (calm when Healthy, fast when down).
* - reportSuccess / reportFailure from the API interceptor, the audio data
* source, and the playback-error reporter.
* - recheck() from pull-to-refresh and the banner.
* - a forced probe when the app returns to the foreground (#1209). Without
* it a stale ServerDown outlived the condition that caused it: the poll
* loop's delay() is throttled while screen-off/doze, so recovery waited on
* whenever the OS next let the loop run. Meanwhile ServerDown makes
* OfflineGatedDataSource refuse every uncached track, so the app declined
* to play music that would have played fine.
* - a forced probe when the app returns to the foreground. The poll
* loop's delay() is throttled while screen-off/doze, so without it a
* stale ServerDown would outlive its cause until the OS next let the
* loop run — and ServerDown makes OfflineGatedDataSource refuse every
* uncached track, declining music that would play fine.
*
* Version compatibility is a byproduct of the same /healthz response.
*
@@ -112,7 +110,7 @@ class NetworkStatusController @Inject constructor(
/**
* App returned to the foreground — probe now rather than waiting for the
* poll loop (#1209).
* poll loop.
*
* The link-return probe in `init` does NOT cover this: it fires on a
* connectivity *change*, and an app backgrounded on stable Wi-Fi sees none.
@@ -172,7 +170,7 @@ class NetworkStatusController @Inject constructor(
private suspend fun probeOnce(force: Boolean = false) {
// Startup guard: don't poll the localhost placeholder before AuthStore
// hydrates the real base URL — that false failure used to flash the banner.
// hydrates the real base URL — that false failure would flash the banner.
if (authStore.baseUrl.value == AuthStore.DEFAULT_BASE_URL) return
val now = System.currentTimeMillis()
if (!force && now - lastProbeAtMs.get() < ARBITRATE_MIN_GAP_MS) return
@@ -5,15 +5,14 @@ internal const val CORROBORATION_WINDOW_MS = 30_000L
internal const val CORROBORATION_OP_THRESHOLD = 2
/**
* Minimum gap between op failures for them to count as SEPARATE evidence
* (#1209).
* Minimum gap between op failures for them to count as SEPARATE evidence.
*
* A link handoff fails every in-flight request at once, so a burst is one
* event producing N failures — not N independent observations that the server
* is gone. Without this, two simultaneous failures corroborated each other
* straight to Unreachable, and ServerDown makes OfflineGatedDataSource refuse
* every uncached track. The app declined to play music that would have played
* fine, for a blip that had already resolved.
* is gone. Counted separately, two simultaneous failures would corroborate
* each other straight to Unreachable, and ServerDown makes
* OfflineGatedDataSource refuse every uncached track: the app would decline
* music that plays fine, for a blip that had already resolved.
*
* 3s is comfortably above the sub-second window an OS handoff occupies while
* still letting a genuine outage corroborate within seconds once a client
@@ -38,7 +38,7 @@ import javax.inject.Inject
import javax.inject.Singleton
/**
* On-device diagnostics collector (M9). Active only while the account's
* On-device diagnostics collector. Active only while the account's
* debug mode is enabled (server flag, read via AuthController.currentUser)
* AND the user hasn't opted out locally. When active it taps existing
* connectivity / player / UPnP / power seams READ-ONLY and writes a
@@ -196,13 +196,11 @@ class DiagnosticsReporter @Inject constructor(
/**
* Catch the renderer rapidly leaving and re-entering PLAYING.
*
* The operator reports the Sonos "play pause play pause, like someone
* pressing it every half second", usually as a track starts, cleared by a
* manual pause or skip. Nothing here could see that: `player_state`
* carries source/loading/error but not playing, `track_change` needs the
* index to move, and the heartbeat samples once per 45s. The symptom fell
* through every existing collector, which is why it has only ever been
* described and never measured.
* The symptom: a Sonos rapidly play-pause-play-pausing, about every half
* second, usually as a track starts, cleared by a manual pause or skip.
* No other collector sees it: `player_state` carries
* source/loading/error but not playing, `track_change` needs the index
* to move, and the heartbeat samples once per 45s.
*
* Records every raw transport change (cheap — steady playback produces
* a couple per track) and, when they come in a burst, one summary event
@@ -27,7 +27,7 @@ import javax.inject.Singleton
/**
* Drains the [DiagnosticEventDao] ring buffer to POST /api/diagnostics
* (M9). Triggers mirror [com.fabledsword.minstrel.cache.mutations.MutationReplayer]:
*. Triggers mirror [com.fabledsword.minstrel.cache.mutations.MutationReplayer]:
* a periodic tick, a sign-in (sessionCookie non-null), and a server-health
* recovery to [ServerHealth.Healthy] — so a dead-zone's buffered events
* upload the moment the link comes back.
@@ -6,10 +6,9 @@ import com.fabledsword.minstrel.player.TransportObservation
* Decides when a run of renderer transport changes is a *flap* — the renderer
* repeatedly failing to settle — rather than an ordinary track transition.
*
* The operator reports the Sonos "play pause play pause, like someone pressing
* it every half second", usually as a track starts. No diagnostic event could
* see it, so it has been described several times and measured never. This is
* the rule that decides when an episode is worth writing down.
* The symptom: a Sonos rapidly play-pause-play-pausing, about every half
* second, usually as a track starts. No other diagnostic event records it;
* this is the rule that decides when an episode is worth writing down.
*
* Pure decision state, like [com.fabledsword.minstrel.player.RemoteStallWatchdog]:
* the caller owns the flow and the recording, this only answers "is this an
@@ -29,16 +29,11 @@ enum class RequestOutcome { ACCEPTED, QUEUED }
/**
* Read-through Discover surface: catalogue search, suggestion feed, and
* the request-creation write path. Mirrors the Flutter
* `DiscoverApi` + `mutationQueueProvider` integration —
* `feedback_offline_first_for_server_writes` says writes never go
* fire-and-forget, so `createRequest` falls back to the
* MutationQueue on transport failure.
* the request-creation write path. Writes never go fire-and-forget, so
* `createRequest` falls back to the MutationQueue on transport failure.
*
* Reads are not cached locally yet — discovery results are inherently
* transient (60s server-side LRU); the cache wins are minor and
* unblock no UX. Phase 13's offline pass adds an offline scrollback
* for the suggestion feed only.
* Reads are not cached locally — discovery results are inherently
* transient (60s server-side LRU), so a local cache would win little.
*/
@Singleton
class DiscoverRepository @Inject constructor(
@@ -54,7 +49,7 @@ class DiscoverRepository @Inject constructor(
api.listSnoozes().map { it.toDomain() }
/**
* Parks a suggestion ("not right now"). Offline-first per rule #100: on
* Parks a suggestion ("not right now"). Offline-first: on
* transport failure the target state is queued for the replayer rather
* than dropped.
*
@@ -195,8 +195,8 @@ private fun KindChips(kind: RequestKind, onChange: (RequestKind) -> Unit) {
/**
* The snooze surface's data and callbacks, bundled rather than threaded
* through as four more parameters — the pane grew from one action to three
* with slice 4 and the signatures stopped being readable.
* through as separate parameters: the pane carries three actions, and the
* signatures stop being readable otherwise.
*/
private data class SnoozeUi(
val locallySnoozedMbids: Set<String>,
@@ -240,9 +240,9 @@ private fun SuggestionsList(
) {
item { SuggestionsHeader() }
if (items.isEmpty()) {
// An empty deck used to mean one thing — no listening signal yet.
// With snoozing it can also mean "you parked them all", and telling
// that user to go listen to something would be wrong advice.
// An empty deck means one of two things — no listening signal yet,
// or "you parked them all" — and telling the second user to go
// listen to something would be wrong advice.
item {
CenteredMessage(
if (snoozeUi.snoozes.isEmpty()) {
@@ -79,8 +79,8 @@ internal fun SuggestionTile(
IconButton(onClick = onSnooze) {
Icon(
imageVector = Lucide.Clock,
// Rule #101: the label states what happens, and passes no
// judgement on the music. Never "not for me".
// The label states what happens and passes no judgement on the
// music: never "not for me".
contentDescription = "Not right now — hide ${s.name} for a while",
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
@@ -173,7 +173,7 @@ private fun Avatar(imageUrl: String, fallback: ImageVector, circular: Boolean) {
.background(MaterialTheme.colorScheme.surfaceVariant),
contentAlignment = Alignment.Center,
) {
// The server hands back its own art endpoint (relative, M502 #5519),
// The server hands back its own art endpoint (relative),
// which answers 404 when no source has an image: ServerImage resolves
// the path and shows the icon while loading and on a miss.
ServerImage(
@@ -68,7 +68,7 @@ class DiscoverViewModel @Inject constructor(
init {
loadSuggestions()
// Screen-level auto-recovery (issue #1245): re-run whichever pane
// Screen-level auto-recovery: re-run whichever pane
// failed while the server was unreachable once health returns.
viewModelScope.launch {
networkStatus.recoveries().collect {
@@ -54,7 +54,7 @@ private const val EVENTS_BUFFER_CAPACITY = 64
* retry. Without this a single blip silently kills cross-device
* reactivity until the next app launch.
* - [connected] says whether a stream is open. Background delivery
* (M489 #5347) catches up on every rising edge: nothing replays a
* catches up on every rising edge: nothing replays a
* frame sent while the stream was down.
*
* The URL passes through the placeholder host that
@@ -4,8 +4,7 @@ import kotlinx.serialization.Serializable
import kotlinx.serialization.json.JsonObject
/**
* Parsed event from the server's SSE stream. Mirrors
* the Flutter client's `LiveEvent`.
* Parsed event from the server's SSE stream.
*
* - [kind] is the SSE `event:` field (e.g. "track.liked", "playlist.deleted").
* - [userId] is the actor whose user-scoped state changed (empty for
@@ -51,7 +51,7 @@ class LiveEventsDispatcher @Inject constructor(
"artist.liked",
"artist.unliked",
-> refreshLikes()
// M489: a contentless nudge; the inbox refetches its newest page.
// a contentless nudge; the inbox refetches its newest page.
"notification.created" -> refreshNotifications()
}
// Other kinds (playlist.*, quarantine.*, request.status_changed,
@@ -3,14 +3,14 @@ package com.fabledsword.minstrel.events
import kotlin.random.Random
/**
* How long [EventsStream] waits before reconnecting (M489 #5347).
* How long [EventsStream] waits before reconnecting.
*
* The stream is wanted around the clock once notifications arrive with the app
* closed, so a server that is down overnight must not cost a radio wakeup
* every few seconds: Roundtable's flat 2s came to about 43,000 of them. The
* wait doubles from [BASE_MS] to [MAX_MS]. The jitter spreads the moment every
* phone on the server reconnects, which is when the server has just come back
* and can least take a spike.
* every few seconds: a flat 2s retry is 1,800 wakeups an hour. The wait
* doubles from [BASE_MS] to [MAX_MS]. The jitter spreads the moment every
* phone on the server reconnects, which is when the server has just come
* back and can least take a spike.
*
* A network coming up (a hint, never a gate) or the app coming to the
* foreground reconnects at once and starts the ladder over.
@@ -33,9 +33,7 @@ data class HistoryPage(
)
/**
* Cache-first listening history. Mirrors Flutter's `_historyProvider`
* (`cacheFirst` over the `cached_history_snapshot` drift row with
* `alwaysRefresh`) using the native Room + Flow idiom:
* Cache-first listening history, in the Room + Flow idiom:
*
* - [observeHistory] emits the cached snapshot instantly (null until
* the first fetch lands), so the History tab paints from disk on
@@ -47,8 +45,8 @@ data class HistoryPage(
*
* The whole page is stored as one JSON blob (single-row table) rather
* than per-row Room entities — history is a read-only timestamp-keyed
* snapshot, so a blob is the simplest faithful mirror of the Drift
* snapshot and avoids ordering/paging bookkeeping for no UX gain.
* snapshot, so a blob is the simplest faithful copy and avoids
* ordering/paging bookkeeping for no UX gain.
*/
@Singleton
class HistoryRepository @Inject constructor(
@@ -86,7 +86,7 @@ class HistoryTabViewModel @Inject constructor(
init {
refresh()
// Screen-level auto-recovery (issue #1245): re-pull when server
// Screen-level auto-recovery: re-pull when server
// health returns, so a load that failed offline heals unprompted.
viewModelScope.launch {
networkStatus.recoveries().collect { refresh() }
@@ -99,7 +99,7 @@ class HistoryTabViewModel @Inject constructor(
.onFailure { refreshError.value = ErrorCopy.fromThrowable(it) }
}
/** Single-track play, matching the Flutter history-row behavior. */
/** Plays the tapped track on its own. */
fun playTrack(track: TrackRef) {
if (track.streamUrl.isEmpty()) return
player.setQueue(listOf(track), initialIndex = 0, source = "history")
@@ -33,8 +33,7 @@ import javax.inject.Singleton
* [MetadataProvider] Flow that emits the cached row (or null while it
* hydrates) and fires a dedup'd, concurrency-gated on-miss fetch. A
* null [HomeTile.value] renders as a skeleton in position; the tile
* reveals when the fetch lands and Room re-emits. Mirrors Flutter's
* per-item tile providers.
* reveals when the fetch lands and Room re-emits.
*
* `refreshIndex()` pulls `GET /api/home/index`, swaps all sections in
* one transaction (so the rows update together in a single emission
@@ -99,8 +98,8 @@ class HomeRepository @Inject constructor(
* One transaction for all seven sections is deliberate: Room notifies
* observers once, on commit, so Home swaps from the old content to
* the new in a single emission. Per-section, un-transacted writes
* made every row visibly collapse to empty and refill, one after
* another (issue #2327).
* would make every row visibly collapse to empty and refill, one
* after another.
*/
suspend fun refreshIndex() {
val wire = api.getHomeIndex()
@@ -141,7 +140,7 @@ class HomeRepository @Inject constructor(
* the id list instead means a section whose contents didn't actually
* move never restarts the `flatMapLatest` below, which would
* otherwise tear down and rebuild all of its tiles' hydration flows
* and flicker unchanged tiles (issue #2327).
* and flicker unchanged tiles.
*/
private fun observeSectionIds(section: String): Flow<List<String>> =
homeIndexDao.observeBySection(section)
@@ -133,7 +133,7 @@ private const val RECENTLY_ADDED_GRID_HEIGHT_DP = 440
// "Updating your mixes…" veil. UpdateVeilController decides both whether it
// appears at all — only when a refresh actually changes something — and how
// long it stays, by watching the screen settle rather than by a fixed delay,
// which lowered it while tiles and artwork were still landing (#2327).
// which would lower it while tiles and artwork are still landing.
// Near-opaque (VEIL_ALPHA) so the section churn never bleeds through.
private const val VEIL_WIPE_MS = 280
private const val VEIL_ALPHA = 0.96f
@@ -244,9 +244,8 @@ class HomeViewModel @Inject constructor(
/**
* Tap on a Most-played tile: replace the queue with the section's
* tracks and start at the tapped index. Mirrors Flutter's
* `_resolveSectionTracks` — Home's section is itself the playable
* unit, not a list of album-detail links.
* tracks and start at the tapped index. Home's section is itself the
* playable unit, not a list of album-detail links.
*/
fun playMostPlayed(tracks: List<com.fabledsword.minstrel.models.TrackRef>, startIndex: Int) {
if (tracks.isEmpty()) return
@@ -259,13 +258,11 @@ class HomeViewModel @Inject constructor(
/**
* Tap the album play-button overlay: fetch the album detail and
* play its tracks from the first. Mirrors Flutter's AlbumCard
* `_playAlbum`. Suspending so the button's spinner waits for the
* fetch + queue setup; the inner work is launched in viewModelScope
* so cancellation from leaving the screen mid-fetch doesn't kill
* playback. Errors surface via the snackbar channel (Android
* improvement over Flutter's silent-fail; matches the existing
* ArtistDetail play handler).
* play its tracks from the first. Suspending so the button's spinner
* waits for the fetch + queue setup; the inner work is launched in
* viewModelScope so cancellation from leaving the screen mid-fetch
* doesn't kill playback. Errors surface via the snackbar channel,
* like the ArtistDetail play handler.
*/
suspend fun playAlbum(albumId: String) {
viewModelScope.launch {
@@ -288,9 +285,8 @@ class HomeViewModel @Inject constructor(
/**
* Tap the artist play-button overlay: fetch every track across the
* artist's albums, Fisher-Yates shuffle, play from index 0. Mirrors
* Flutter's ArtistCard `_playArtistShuffle`. Empty + error semantics
* follow [playAlbum].
* artist's albums, Fisher-Yates shuffle, play from index 0. Empty +
* error semantics follow [playAlbum].
*/
suspend fun playArtistShuffled(artistId: String) {
viewModelScope.launch {
@@ -315,9 +311,8 @@ class HomeViewModel @Inject constructor(
* Tap the playlist play-button overlay: fetch the playlist's tracks
* (rotation-aware shuffle for refreshable system playlists, stored
* order for everything else), 8s timeout, play from index 0.
* Mirrors Flutter PlaylistCard `_playPlaylist`. Empty-mix and
* timeout get their own copy strings; other errors fall through to
* [ErrorCopy.fromThrowable].
* Empty-mix and timeout get their own copy strings; other errors
* fall through to [ErrorCopy.fromThrowable].
*/
suspend fun playPlaylist(playlist: PlaylistRef) {
viewModelScope.launch {
@@ -366,9 +361,9 @@ class HomeViewModel @Inject constructor(
fun retry() = veil.request(userInitiated = true)
/**
* Manual pull-to-refresh. Goes behind the veil like every other refresh
* (operator call, 2026-07-31: the churn a pull causes is identical to
* the automatic paths, and a small spinner didn't hide it).
* Manual pull-to-refresh. Goes behind the veil like every other
* refresh: a pull churns the screen exactly as the automatic paths
* do, and a small spinner does not hide it.
*
* Suspends until the veil has taken over OR the session has finished,
* so the pull indicator hands off to exactly one successor: the veil if
@@ -488,7 +483,7 @@ class HomeViewModel @Inject constructor(
* Only user-initiated sessions say anything. The same outcome from a
* background check — the initial load, the 03:00 rebuild, a reconnect —
* is noise, and "Already up to date" on every launch would be worse
* than silence (operator's call, 2026-07-31).
* than silence.
*/
private fun reportRefreshOutcome(result: VeilSessionResult) {
if (!result.userInitiated) return
@@ -505,7 +500,7 @@ class HomeViewModel @Inject constructor(
*
* Raised only when a refresh actually changes what's on screen, and then
* held until Home settles — tiles hydrated, artwork loaded — instead of
* for a fixed delay after the network pull returns (issue #2327). A
* for a fixed delay after the network pull returns. A
* refresh that returns what's already cached shows no veil at all;
* [reportRefreshOutcome] tells the user instead, if they asked.
*/
@@ -515,9 +510,9 @@ class HomeViewModel @Inject constructor(
// Every refresh path goes through the controller, which decides
// per session whether to raise the veil. That includes the initial
// load: over a warm cache it's a full re-pull that churns every
// section, and it used to run completely unveiled.
// section.
veil.request()
// Screen-level auto-recovery (issue #1245): a Home that failed to
// Screen-level auto-recovery: a Home that failed to
// load while the server was unreachable re-pulls itself the moment
// health returns — same idiom as SyncController, one layer up.
// This is also the recovery that keeps trying after the veil has
@@ -525,9 +520,8 @@ class HomeViewModel @Inject constructor(
viewModelScope.launch {
networkStatus.recoveries().collect { veil.request() }
}
// Server-side changes that rewrite what Home renders (#968 and
// the 2026-07-31 widening) re-pull behind the veil. Mirrors the
// web SSE consumer.
// Server-side changes that rewrite what Home renders re-pull
// behind the veil, as the web SSE consumer does.
viewModelScope.launch {
eventsStream.events
.filter { it.kind in VEILED_EVENT_KINDS }
@@ -758,8 +752,7 @@ private fun HomeSuccessContent(
// Always rendered: real system/user playlists, with
// placeholder cards filling the For You / Discover slots
// that haven't generated yet. When offline, the cache-backed
// pool cards lead the row. Songs-like now lives in its own
// dedicated row below (#1491), no longer inside this carousel.
// pool cards lead the row. Songs-like has its own row below.
PlaylistsRow(
title = "Playlists",
rowItems = buildPlaylistsRow(sections.playlists, systemStatus, offline),
@@ -769,10 +762,9 @@ private fun HomeSuccessContent(
onPlayPlaylist = onPlayPlaylist,
)
}
// Songs-like is the best-performing surface (#1491) — promoted out
// of the Playlists carousel into its own row so it shows a wider
// spread of "Songs like {artist}" mixes. Hidden when there's
// nothing to show (offline with none cached).
// Songs-like gets its own row so it can show a wider spread of
// "Songs like {artist}" mixes than a carousel slot allows. Hidden
// when there's nothing to show (offline with none cached).
songsLikeSection(
playlists = sections.playlists,
status = systemStatus,
@@ -805,11 +797,11 @@ private fun HomeSuccessContent(
}
/**
* The dedicated "Songs like…" row (#1491). Reuses [PlaylistsRow]'s card
* The dedicated "Songs like…" row. Reuses [PlaylistsRow]'s card
* rendering with a distinct title; hidden entirely when there's nothing
* to show (offline with no cached mixes). Online with none generated yet
* still shows a few placeholders so the building / seed-needed state is
* visible, matching the pre-promotion carousel behavior.
* visible.
*/
private fun LazyListScope.songsLikeSection(
playlists: List<PlaylistRef>,
@@ -1000,7 +992,7 @@ private fun EmptySection(title: String, body: String) {
/**
* Rediscover may have both an albums sub-row and an artists sub-row. When
* both are present the artist row gets an empty title so the section
* reads as a single visual block — matches the Flutter behavior.
* reads as a single visual block.
*/
@Composable
private fun RediscoverBlock(
@@ -1164,18 +1156,12 @@ enum class OfflinePoolKind(val label: String) {
* placeholder otherwise), then the secondary system kinds (deep cuts /
* rediscover / new for you / on this day / first listens) when they exist —
* no placeholders for these since they're conditional on library shape — then
* user-owned playlists. Songs-like has its own dedicated row (#1491) via
* [buildSongsLikeRow] and no longer appears in this carousel.
* user-owned playlists. Songs-like has its own row via [buildSongsLikeRow].
*
* Offline: the two cache-backed pools (Recently played, Liked) lead, then the
* same real playlists in curated order but stably partitioned fully-cached
* first / greyed after, and the "building/pending" placeholders are dropped
* (they need the server to generate, so they're meaningless offline).
*
* Diverges from Flutter (the Flutter client
* `_buildPlaylistsRow`) which only shows the 5 fixed slots and never
* surfaces the secondary kinds on Home. Operator authorized the
* divergence on 2026-06-01; web UI catch-up tracked as task #53.
*/
internal fun buildPlaylistsRow(
owned: List<PlaylistRef>,
@@ -1204,7 +1190,7 @@ private fun buildOnlineRow(
out += owned.firstOrNull { it.systemVariant == "discover" }
?.let { PlaylistRowItem.Real(it) }
?: PlaylistRowItem.Placeholder("Discover", variantFor("discover", status))
// Songs-like is no longer here — it has its own dedicated row (#1491).
// Songs-like is not here: it has its own row.
for (variant in SECONDARY_SYSTEM_VARIANTS) {
owned.firstOrNull { it.systemVariant == variant }?.let { out += PlaylistRowItem.Real(it) }
}
@@ -1213,8 +1199,8 @@ private fun buildOnlineRow(
}
/**
* Builds the dedicated Songs-like row (#1491): all "Songs like {artist}"
* mixes the server generated, no longer capped to the 3 carousel slots.
* Builds the dedicated Songs-like row: every "Songs like {artist}" mix
* the server generated, uncapped.
*
* Online: every generated mix as a real card; when none exist yet, a few
* placeholders so the building / seed-needed state stays visible.
@@ -1244,7 +1230,7 @@ internal fun buildSongsLikeRow(
* Must mirror [buildOnlineRow]'s slot order — the offline row reuses this and
* only differs by dropping placeholders + partitioning available-first.
* Songs-like is excluded here too — it renders in its own row via
* [buildSongsLikeRow] in both online and offline modes (#1491).
* [buildSongsLikeRow] in both online and offline modes.
*/
private fun orderedRealPlaylists(owned: List<PlaylistRef>): List<PlaylistRef> {
val out = mutableListOf<PlaylistRef>()
@@ -1266,7 +1252,7 @@ private fun variantFor(slot: String, s: SystemPlaylistsStatus): String = when {
// How many "Songs like…" placeholder cards the dedicated row shows while
// the mixes haven't generated yet (building / seed-needed). Real mixes,
// once generated, are shown in full and no longer capped by this (#1491).
// once generated, are shown in full, uncapped.
private const val SONGS_LIKE_PLACEHOLDER_SLOTS = 3
/**
@@ -1308,8 +1294,7 @@ private fun ArtistsRow(
/**
* Most-played section. Renders compact track tiles (cover + title + artist).
* Tapping a tile replaces the queue with the entire section starting
* at that tile — mirrors Flutter's `_resolveSectionTracks` behaviour
* where Home sections are themselves playable units.
* at that tile — Home sections are themselves playable units.
*/
@Composable
private fun MostPlayedRow(
@@ -1373,12 +1358,8 @@ private const val MOST_PLAYED_TILE_HEIGHT_DP = 56
private const val MOST_PLAYED_COVER_DP = 48
// 3 rows of MOST_PLAYED_TILE_HEIGHT_DP + 2 * 8dp inter-row spacing,
// rounded up. Mirrors Flutter (`CompactTrackCard` in
// the Flutter client) which
// uses a horizontal-row card pattern - much denser than the square
// per-track tiles that web uses (operator request 2026-06-01: "in the
// flutter iteration the tiles were different and smaller so more of
// them could fit in a smaller space").
// rounded up. Horizontal-row cards, much denser than the square
// per-track tiles web uses, so more tracks fit in less space.
private const val MOST_PLAYED_GRID_HEIGHT_DP = 200
@Composable
@@ -23,10 +23,9 @@ import javax.inject.Singleton
* server and upsert into Room — the Flow emissions then propagate
* automatically.
*
* Constructs its own LibraryApi from the shared Retrofit. Per the
* "repositories own their interfaces" pattern adopted in Phase 5.1's
* NetworkModule, we don't @Provides per-endpoint Retrofit interfaces
* — fewer Hilt bindings, locality of reference.
* Constructs its own LibraryApi from the shared Retrofit: repositories
* own their interfaces (see NetworkModule), so there is no @Provides
* per endpoint — fewer Hilt bindings, locality of reference.
*/
// LibraryRepository owns reads + refreshes for three entity families
// (artists / albums / tracks) plus shuffle. Function count scales with
@@ -46,8 +45,7 @@ class LibraryRepository @Inject constructor(
// Joins each artist to its first cached album (by sort title) so the
// tile cover derives from /api/albums/{id}/cover — cached_artists
// stores no cover and sync carries none. Mirrors Flutter's
// artistTileProvider JOIN.
// stores no cover and sync carries none.
fun observeArtists(): Flow<List<ArtistRef>> =
combine(artistDao.observeAll(), albumDao.observeAll()) { artists, albums ->
val firstAlbumByArtist = albums
@@ -161,13 +159,12 @@ class LibraryRepository @Inject constructor(
suspend fun shuffleLibrary(limit: Int = SHUFFLE_DEFAULT_LIMIT): List<TrackRef> =
api.shuffleLibrary(limit = limit).map { it.toDomain() }
// ---- Browse axes (#367 / #2467) ----
// ---- Browse axes ----
//
// Server-backed rather than cache-first, unlike everything above. The
// cache mirrors the whole library but includes tracks whose files are
// missing, with no flag to spot them (#2704), while the server's index
// excludes them -- so a locally-derived index would quietly disagree with
// the web client's. Revisit when #2704 lands.
// Server-backed rather than cache-first, unlike everything above: the
// server's index leaves out tracks whose files are missing and is the
// one the web client shows. See LibraryApi for why the cache's own
// missing mark is not used to build it locally.
/** Genre index, ordered by track count then name (server order). */
suspend fun genres(): List<GenreCount> =
@@ -67,7 +67,7 @@ class AlbumDetailViewModel @Inject constructor(
init {
refresh()
// Screen-level auto-recovery (issue #1245): a detail that failed to
// Screen-level auto-recovery: a detail that failed to
// load while the server was unreachable re-fetches when health
// returns, so coming back to the screen shows content, not an error.
viewModelScope.launch {
@@ -310,9 +310,9 @@ private fun ArtistHeader(
@Composable
private fun ArtistAvatar(artist: ArtistRef, fallbackAlbumId: String? = null) {
// Server coverUrl wins; otherwise mirror the server's "use the
// first album's cover" rule via the loaded album list (Flutter
// _ArtistAvatar). Bare Lucide.User only when neither exists.
// Server coverUrl wins; otherwise use the first loaded album's
// cover, the same rule the server applies. Bare Lucide.User only
// when neither exists.
val model = when {
artist.coverUrl.isNotEmpty() -> artist.coverUrl
fallbackAlbumId != null -> albumCoverPath(fallbackAlbumId)
@@ -72,7 +72,7 @@ class ArtistDetailViewModel @Inject constructor(
init {
refresh()
// Screen-level auto-recovery (issue #1245). From Error, redo the
// Screen-level auto-recovery. From Error, redo the
// whole load; from Success, re-pull just the secondary sections —
// their fetch failures are swallowed to emptyList, so a dead zone
// during the first load leaves them permanently absent otherwise.
@@ -139,10 +139,9 @@ class ArtistDetailViewModel @Inject constructor(
/**
* Pull every track across the artist's albums, shuffle, and start
* playing. Mirrors Flutter's artist Play button — pressing Play on
* an artist is implicitly "shuffle this artist", not "play album by
* album in tracklist order". Network round-trip per click; fine
* because the user explicitly tapped Play.
* playing. Pressing Play on an artist means "shuffle this artist",
* not "play album by album in tracklist order". One network
* round-trip per click; fine because the user explicitly tapped Play.
*/
fun playArtist() {
viewModelScope.launch {
@@ -42,15 +42,12 @@ data class AlbumBrowseState(
}
/**
* Backs the Genres and Years tabs (#2467), mirroring the web surfaces #367
* shipped.
* Backs the Genres and Years tabs, the same browse axes the web client has.
*
* Both indexes come from the server, which is a deliberate departure from the
* cache-first Artists/Albums tabs beside them: the local cache includes tracks
* whose files are missing and cannot tell you which (#2704), while the server's
* index excludes them, so a locally-derived index would disagree with the web
* client's. These two tabs therefore need a connection; the empty states say so
* rather than looking broken.
* Both indexes come from the server, unlike the cache-first Artists/Albums
* tabs beside them: the server's index leaves out tracks whose files are
* missing and matches the web client's. These two tabs therefore need a
* connection; the empty states say so rather than looking broken.
*/
// Two browse axes, each with an index, a filter/sort or grouping, a
// drill-down and a pager. The function count is two axes' worth of a
@@ -41,7 +41,7 @@ import com.fabledsword.minstrel.shared.widgets.EmptyState
import com.fabledsword.minstrel.shared.widgets.ErrorRetry
/**
* Genres tab (#2467) — the Android half of the browse axis #367 shipped on web.
* Genres tab: the genre browse axis, as on web.
*
* Two states in one tab rather than a navigation destination: the index, and
* the albums for a chosen genre. Back returns to the index. A route would have
@@ -54,17 +54,11 @@ import com.fabledsword.minstrel.shared.widgets.SkeletonArtistTile
/**
* Library tab. Seven-tab TabBar (Artists / Albums / Genres / Years /
* History / Liked / Hidden), matching the web client's library tab bar.
* Genres and Years arrived with #2467; the rest predate it and mirrored
* the Flutter client.
*
* Artists + Albums are wired against the existing LibraryViewModel
* (cache-first reads of cached_artists / cached_albums). The other
* three tabs render placeholder EmptyStates pointing at the upcoming
* data-layer phases:
* - History → Phase 9 (server-side /api/me/history wiring)
* - Liked → Phase 8 (likes write path + cached_likes hydration)
* - Hidden → Phase 10 (quarantine flag/unflag UI lives in Admin slice
* but the user-side "my hidden tracks" view comes here)
* Artists + Albums are cache-first reads of cached_artists /
* cached_albums through LibraryViewModel; Genres and Years read the
* server through BrowseViewModel; History, Liked and Hidden are their
* own tabs ([HistoryTab], [LikedTab], [HiddenTab]).
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -160,7 +154,7 @@ private const val TAB_LIKED = 5
private const val TAB_HIDDEN = 6
// Genres and Years sit straight after Albums, matching the web tab bar's
// order (#2467) -- they are browse axes over the same albums, so they belong
// order -- they are browse axes over the same albums, so they belong
// beside them rather than after the personal tabs.
private val LIBRARY_TABS =
listOf("Artists", "Albums", "Genres", "Years", "History", "Liked", "Hidden")
@@ -26,7 +26,7 @@ import com.fabledsword.minstrel.shared.widgets.EmptyState
import com.fabledsword.minstrel.shared.widgets.ErrorRetry
/**
* Years tab (#2467). Same two-state shape as [GenresTab]: the decade-grouped
* Years tab. Same two-state shape as [GenresTab]: the decade-grouped
* index, then the albums for a chosen year.
*
* Albums with no release date are absent from this axis entirely — the server
@@ -25,16 +25,15 @@ import com.fabledsword.minstrel.shared.widgets.PlayCircleButton
import androidx.compose.ui.Alignment
/**
* One album tile. Mirrors the Flutter `AlbumCard` (~176dp wide, 144dp
* square cover). Tap fires `onClick` — wired to `AlbumDetail` nav in
* Phase 5.5. The tile stashes its `album` in [LocalDetailSeedCache]
* One album tile (~176dp wide, 144dp square cover). Tap fires
* `onClick`. The tile stashes its `album` in [LocalDetailSeedCache]
* before firing onClick so the detail screen's AppBar renders title /
* cover / counts immediately without waiting for the fetch.
*
* [onPlay] is an opt-in play-overlay handler. When non-null, a 44dp
* circular play button is overlaid at the bottom-right of the cover
* (mirrors Flutter's PlayCircleButton). Only Home wires it currently;
* Library / Discover / detail surfaces leave it null.
* circular play button is overlaid at the bottom-right of the cover.
* Only Home wires it currently; Library / Discover / detail surfaces
* leave it null.
*/
@Composable
fun AlbumCard(
@@ -28,15 +28,13 @@ import com.fabledsword.minstrel.shared.widgets.PlayCircleButton
/**
* One artist tile. Circular cover; name centered beneath. Tap fires
* `onClick` — wired to `ArtistDetail` nav in Phase 5.5. Stashes the
* `artist` in [LocalDetailSeedCache] on click so the ArtistDetail
* AppBar renders the name before the fetch lands.
* `onClick`. Stashes the `artist` in [LocalDetailSeedCache] on click
* so the ArtistDetail AppBar renders the name before the fetch lands.
*
* [onPlay] is an opt-in play-overlay handler. When non-null, a 44dp
* circular play button is overlaid at the bottom-right of the avatar
* (mirrors Flutter's PlayCircleButton). Only Home wires it currently.
* Padding is 4dp instead of 6dp because the circular avatar pulls the
* button visually inward at the corner.
* circular play button is overlaid at the bottom-right of the avatar.
* Only Home wires it currently. Padding is 4dp instead of 6dp because
* the circular avatar pulls the button visually inward at the corner.
*/
@Composable
fun ArtistCard(
@@ -24,26 +24,21 @@ import javax.inject.Inject
import javax.inject.Singleton
/**
* Cache-first reads + offline-first writes for likes. Mirrors the
* Flutter `likesControllerProvider` + `cached_likes`-driven Liked tab
* pattern.
* Cache-first reads + offline-first writes for likes.
*
* Local `userId` discriminator is the server-side user UUID
* (resolved via [AuthController.currentUser]); falls back to
* [ANONYMOUS_USER_ID] when no user is signed in so existing local
* cache rows from pre-#576 builds remain queryable until the first
* authenticated [refreshIds] call overwrites them. On user-switch
* (sign-out / sign-in as different user) the OUTGOING user's
* cached_likes rows are wiped so they don't leak into the new
* session — drift #576 audit caught the leak.
* The local `userId` discriminator is the server-side user UUID
* (resolved via [AuthController.currentUser]); it falls back to
* [ANONYMOUS_USER_ID] when no user is signed in, so rows in that bucket
* stay queryable until the first authenticated [refreshIds] call
* overwrites them. On user-switch (sign-out / sign-in as a different
* user) the OUTGOING user's cached_likes rows are wiped so they don't
* leak into the new session.
*
* Write path follows `feedback_offline_first_for_server_writes` —
* never fire-and-forget:
* Writes never go fire-and-forget:
* 1. Optimistic Room mutation (UI flips instantly).
* 2. Best-effort server call.
* 3. On exception → enqueue in MutationQueue for the Phase 12.2
* replayer to drain when connectivity returns.
*
* 3. On exception → enqueue in MutationQueue for the replayer to
* drain when connectivity returns.
* Reads expose Flows of hydrated DomainRefs. Per-tile hydration goes
* through the existing Library DAOs — IDs without cached entity rows
* are silently dropped (the SyncController fills the gap async).
@@ -62,12 +57,10 @@ class LikesRepository @Inject constructor(
private val api: LikesApi = retrofit.create()
init {
// Drift #576: clear the outgoing user's cached_likes rows
// when the signed-in user changes. Mostly a hygiene fix —
// discriminator-based queries already prevent the wrong
// user from SEEING leaked rows, but without this the rows
// pile up forever as different accounts sign in/out on the
// same device.
// Clear the outgoing user's cached_likes rows when the signed-in
// user changes. Discriminator-based queries already keep the wrong
// user from SEEING them; without this the rows pile up forever as
// different accounts sign in/out on the same device.
scope.launch {
var previousId: String? = null
authController.currentUser.collect { user ->
@@ -138,7 +131,7 @@ class LikesRepository @Inject constructor(
*/
suspend fun toggleLike(entityType: String, entityId: String, desiredState: Boolean): Boolean {
// 1. Optimistic Room mutation — scoped to the signed-in user
// (or the legacy "local" fallback for pre-#576 rows).
// (or the "local" bucket when nobody is signed in).
val uid = currentUserId()
if (desiredState) {
likeDao.upsertAll(
@@ -157,8 +150,7 @@ class LikesRepository @Inject constructor(
) {
// Intentional swallow: the queue replays this later; surfacing
// the network error here would force callers (UI) to handle it
// even though it's already been buffered. The replayer logs
// per-attempt diagnostics when it lands in Phase 12.2.
// even though it's already been buffered.
mutationQueue.enqueueLikeToggle(entityType, entityId, desiredState)
false
}
@@ -166,17 +158,14 @@ class LikesRepository @Inject constructor(
/**
* Pulls `GET /api/likes/ids` and atomically replaces the user's
* cached_likes set with the server's canonical view. Called by
* the LikedTab ViewModel on init (and by the SyncController in
* Phase 12).
* cached_likes set with the server's canonical view. Called by the
* LikedTab ViewModel on init and on a live likes event.
*
* Drift #570 fix: previously this called `upsertAll` only, which
* added rows for IDs the server had but never removed rows the
* server no longer surfaced — so a cross-device unlike (user
* likes on web, then unlikes on web) left the Liked tab on
* Android showing the now-unliked entry forever. The atomic
* replaceAllForUser DAO method runs a transactional delete-then-
* insert so the local set is exactly what the server reports.
* A replace, not an upsert: upserts add rows for IDs the server has
* but never remove the ones it dropped, so an unlike made on another
* device would stay on the Liked tab forever. The replaceAllForUser
* DAO method runs a transactional delete-then-insert so the local
* set is exactly what the server reports.
*/
suspend fun refreshIds() {
val uid = currentUserId()
@@ -189,13 +178,10 @@ class LikesRepository @Inject constructor(
}
companion object {
// Pre-auth fallback discriminator. Drift #576: when no user is
// signed in OR for cached_likes rows written by pre-#576
// builds (which all tagged "local"), reads continue to query
// this bucket so the Liked tab isn't suddenly empty after the
// upgrade. The first authenticated refreshIds() call writes
// rows under the real user UUID; reads then transparently
// switch over via currentUserId().
// Fallback discriminator for when no user is signed in. Reads
// query this bucket until the first authenticated refreshIds()
// writes rows under the real user UUID; reads then switch over
// via currentUserId().
const val ANONYMOUS_USER_ID: String = "local"
const val ENTITY_ARTIST: String = "artist"
const val ENTITY_ALBUM: String = "album"
@@ -81,7 +81,7 @@ class LikedTabViewModel @Inject constructor(
init {
refresh()
// Screen-level auto-recovery (issue #1245): re-pull when server
// Screen-level auto-recovery: re-pull when server
// health returns, so a load that failed offline heals unprompted.
viewModelScope.launch {
networkStatus.recoveries().collect { refresh() }
@@ -122,8 +122,8 @@ class LikedTabViewModel @Inject constructor(
/**
* Build a queue from the currently-displayed liked tracks and start
* playback at [initialIndex]. Mirrors Flutter's tap-on-liked-track
* behavior: the visible track list becomes the play queue. Tracks
* playback at [initialIndex]: the visible track list becomes the
* play queue. Tracks
* with empty streamUrl are ignored as queue entries (the row that
* routes here is itself click-gated on streamUrl).
*/
@@ -7,11 +7,11 @@ import javax.inject.Singleton
private const val TOP_N = 8
/**
* Pre-warms the Room cache for likely-tap artist tiles on Home. Mirrors
* Flutter's `MetadataPrefetcher`: conservative — artists only (a single
* round-trip per id), top-8 per call, and once per id per session so
* repeated index refreshes don't re-fetch. Albums are intentionally not
* pre-warmed (their detail fetch fans out a track list).
* Pre-warms the Room cache for likely-tap artist tiles on Home.
* Conservative: artists only (a single round-trip per id), top-8 per
* call, and once per id per session so repeated index refreshes don't
* re-fetch. Albums are intentionally not pre-warmed (their detail fetch
* fans out a track list).
*
* Fetches go through [MetadataProvider.refreshArtist], so they share the
* same per-id dedup AND the 4-slot concurrency gate as on-render
@@ -29,10 +29,8 @@ private const val HTTP_NOT_FOUND = 404
/**
* Cache-first metadata reads with eager on-miss live-fetch.
*
* Mirrors Flutter's tile-provider pattern (`albumTileProvider(id)` /
* `artistTileProvider(id)` / `trackTileProvider(id)`): subscribers
* observe a per-ID Flow that emits the Room row immediately if
* present, and otherwise triggers a single background fetch that
* Subscribers observe a per-ID Flow that emits the Room row immediately
* if present, and otherwise triggers a single background fetch that
* upserts into Room — the flow then re-emits the populated row.
*
* Fetch dedup is per-ID via a ConcurrentHashMap of in-flight Jobs;
@@ -57,9 +55,8 @@ class MetadataProvider @Inject constructor(
private val inFlightTracks = ConcurrentHashMap<String, Job>()
// Caps concurrent on-miss fetches so a cold Home (~30 tiles) doesn't
// fire ~30 parallel /api calls. Mirrors Flutter HydrationQueue's
// _maxConcurrent = 4. Per-id dedup (above) is orthogonal: the gate
// bounds distinct-id fetches; extras suspend on the semaphore.
// fire ~30 parallel /api calls. Per-id dedup (above) is orthogonal:
// the gate bounds distinct-id fetches; extras suspend on the semaphore.
private val fetchGate = Semaphore(MAX_CONCURRENT_FETCHES)
/**
@@ -123,7 +120,7 @@ class MetadataProvider @Inject constructor(
* [onGone] runs when the server answers 404: the row no longer exists
* there (a duplicate merge, a deleted file), so the cached copy is
* dropped. Kept, it stays stale forever and [FreshnessSweeper] asks for
* it every pass (#5654). Any other failure leaves the row for a retry.
* it every pass. Any other failure leaves the row for a retry.
*/
private fun launchDeduped(
inFlight: ConcurrentHashMap<String, Job>,
@@ -14,7 +14,7 @@ data class AdminQuarantineReportRef(
/**
* Aggregated quarantine row in the admin queue — all reports against a
* single track collapsed into per-reason counts plus the underlying
* per-user reports. Mirrors Flutter's `AdminQuarantineItem`.
* per-user reports.
*
* `topReasonSummary` returns the most-cited reason, with a "(+N more)"
* suffix when other distinct reasons exist.
@@ -1,6 +1,6 @@
package com.fabledsword.minstrel.models
/** Domain model for one tag-enrichment provider in the admin surface (#1521). */
/** Domain model for one tag-enrichment provider in the admin surface. */
data class AdminTagSourceRef(
val id: String,
val displayName: String,
@@ -1,7 +1,7 @@
package com.fabledsword.minstrel.models
/**
* One admin-side user row. Mirrors Flutter's `AdminUser`. The
* One admin-side user row. The
* admin-scoped variant exposes `autoApproveRequests` and the
* canonical `createdAt` that the user-scoped /me response omits.
*/

Some files were not shown because too many files have changed in this diff Show More