diff --git a/.github/release.yml b/.github/release.yml
new file mode 100644
index 0000000000..2f05e63124
--- /dev/null
+++ b/.github/release.yml
@@ -0,0 +1,15 @@
+# Groups the release notes GitHub generates by the labels prairie-triage puts on
+# pull requests. Store notes keep only the Features and Fixes sections
+# (scripts/release-version.py). See docs/releasing.md.
+changelog:
+ categories:
+ - title: ๐ Features
+ labels: [enhancement]
+ - title: ๐ Fixes
+ labels: [bug]
+ - title: ๐ Documentation
+ labels: [documentation]
+ - title: ๐ฆ Dependencies
+ labels: [dependencies]
+ - title: Other changes
+ labels: ["*"]
diff --git a/.macroscope/check-run-agents/visible-change-evidence.md b/.macroscope/check-run-agents/visible-change-evidence.md
new file mode 100644
index 0000000000..a689cf6c7a
--- /dev/null
+++ b/.macroscope/check-run-agents/visible-change-evidence.md
@@ -0,0 +1,111 @@
+---
+title: Visible change evidence
+model: gpt-6-luna
+reasoning: high
+input: full_diff
+tools:
+ - browse_code
+ - git_tools
+ - github_api_read_only
+ - modify_pr
+exclude:
+ - ".agents/**"
+ - "docs/superpowers/**"
+conclusion: neutral
+maxRuns: 3
+maxBudgetPerRun: 0.50
+maxBudgetPerPR: 3.00
+---
+
+Prairie requires evidence in every pull request that changes what a user sees. Reviewers should be
+able to see the change without building the branch. Your job is to decide whether this PR changes
+what a user sees, on which devices, and, if it does, whether its Evidence section shows that change
+on each of them. You report; you do not judge whether the change is good.
+
+The rules are in `CONTRIBUTING.md` under "Show visible changes". Read that section first; it is the
+authority if this prompt and it disagree.
+
+## Decide whether the change is visible
+
+Read the PR title, body, and full diff. Trace changed code to what a user would see; do not match
+on file names alone. A change is visible when it alters any of these:
+
+- app UI in the phone app (`androidApp/`), the TV app (`androidTvApp/`), or the code they share
+ (`android-shared/`, `shared/`), including strings and resources: layout, styling, copy,
+ navigation, focus, empty and error states;
+- which items a screen shows, or in what order: search results, home sections, recommendations,
+ library browsing, collections, sorting, or filtering;
+- what an item shows: titles, artwork, descriptions, ratings, badges, episode grouping, or
+ availability;
+- playback behavior a user notices: default audio or subtitle tracks, markers, controls, or resume
+ position.
+
+These are not visible on their own: unit and instrumented tests, CI, fastlane, Gradle and baseline
+profile changes that do not change what ships, internal refactors with identical output, logging,
+and documentation. When the path to the user is real but uncertain, say so and treat the change as
+visible.
+
+## Work out which devices it reaches
+
+The devices are an Android phone, an Android tablet, an Android foldable folded and open (any
+model, such as a Galaxy Z Fold or Pixel Fold), and Android TV. The phone app runs on the first
+three; the TV app runs on Android TV; shared modules reach both apps unless the changed code is only
+called from one. Then trace the layout checks around the changed code:
+
+- `smallestScreenWidthDp`, `widthDp` and `heightDp` thresholds, `isExpandedWindow`, and
+ `isExpandedDetailLayout` decide whether a screen takes its phone or wide layout. A folded
+ foldable behaves like a phone; an open one takes the wide layouts a tablet does.
+- `FoldingFeature` and `isTabletopPlayerPosture` reach only a foldable, open or half-folded.
+
+List each device the change reaches. When you cannot tell whether a device is reached, count it.
+
+## Check the evidence
+
+Find the `## Evidence` section of the PR body. For a visible change, it passes when it has:
+
+- before-and-after images, a recording, or a line starting with `Evidence:
+ https://evidence.prairieserver.org/`;
+- evidence that matches the change: a UI change needs screenshots or a recording; a change to which
+ items appear or their order needs a before and an after of the same screen with the same data;
+- captures for every device the change reaches, with the foldable both folded and open, or a
+ reason the PR gives for each device it leaves out;
+- a recording when the change is about motion, timing, focus movement, or a multi-step flow;
+- the device and build or commit the captures came from.
+
+Evidence on `evidence.prairieserver.org` is private, and you cannot open it. For a section that links
+a page there, count the link as evidence, do not ask for the devices or build in the body, and list
+the devices the page should show so the reviewer can check it.
+
+An explanation of why evidence could not be captured is acceptable; report it so the reviewer can
+decide, and do not count it as a pass. A reason for leaving out a device is acceptable when the code
+you traced agrees with it; when it does not, say which code reaches that device.
+
+For a change that is not visible, the section should say `Evidence: none, no user-visible change`.
+If it is empty or still holds the template table, suggest that line.
+
+You cannot open images. Judge them from their placement, alt text, captions, and surrounding text.
+Never claim an image shows something you could not read.
+
+## How to report
+
+Always write the check run summary. Start with one line: `visible` or `not visible`, and `evidence
+complete`, `evidence incomplete`, `evidence missing`, or `evidence explained as unavailable`. Then
+list the user-visible effects you found, each with the file that causes it and the devices it
+reaches, and what the evidence covers or lacks.
+
+Post one conversation comment on the PR only when a visible change has missing or incomplete
+evidence, or when a change that is not visible has no Evidence line. Name each visible effect that
+lacks evidence, the devices it is missing on, and the kind of evidence it needs, in one line each.
+If the author has replied that evidence is not needed or has explained why it is unavailable,
+accept that and do not raise it again unless new commits change what users see.
+
+## Rules
+
+- Do not change the PR title, body, labels, assignees, or reviewers. Do not @-mention anyone.
+- Comments are public. Do not include hostnames, IP addresses, local paths, credentials, or personal
+ data.
+- If you see private details in the evidence, such as hostnames, URLs, account names, or personal
+ library contents, say that the evidence needs redacting without repeating the details.
+- Write plainly: lead with the finding, use active voice, and skip praise and filler.
+- End every comment with `Automated check: Macroscope check run agent (gpt-6-luna). Evidence was
+ not reviewed for correctness.`
diff --git a/AGENTS.md b/AGENTS.md
index 5058ba6753..2274d04941 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -11,7 +11,7 @@ This repository contains only the Prairie Android clients. Shared Kotlin logic l
- Android TV navigation is Home, available media-type tabs from server libraries, For You (with its Watchlist/Favorites dropdown, mirroring tvOS `.recommendations`), Calendar, and Requests while the server enables it (mirroring tvOS `.requests`), plus search and profile actions. Reading/ebooks are excluded.
- Live TV (Prairie-only) is live on phone and TV, gated by `LiveTvFeatureStore` (the server has channels), and reached from the profile menu (phone `ProfileMenu` via `LocalLiveTvMenuAction`; TV profile dropdown). It is not a bottom-nav or top-menu tab.
- Upstream Silo syncs have silently dropped Prairie-only code. `scripts/check-prairie-invariants.sh` (CI job "Prairie invariants") anchors it; see `docs/upstream-sync.md`. When restoring dropped code, add an anchor.
-- Requests is live on phone and TV, server-gated by `requests_enabled` (phone: profile menu + search; TV: a top-bar tab + search, matching Apple). Admins who can moderate requests approve, decline, and retry them in the Requests screens, as on Apple; limits and auto-approval stay in web admin. Other admin surfaces are not exposed in the Android clients โ no STATS dashboard, and none of the richer admin screens (users/sessions/logs/scans). Session management (seeing where you are signed in and signing other devices out) is not exposed either; device pairing stays. This is a deliberate divergence from Apple, which does surface the STATS dashboard. Do not add any of it back to menus without an explicit product decision.
+- Requests is live on phone and TV, server-gated by `requests_enabled` (phone: profile menu + search; TV: a top-bar tab + search, matching Apple). Request administration (approve, decline, retry, limits, auto-approve) is web-admin only: the Requests screens offer users request, cancel of their own pending request, and status, and never moderation (a source guard test enforces this). Other admin surfaces are not exposed in the Android clients โ no STATS dashboard, and none of the richer admin screens (users/sessions/logs/scans). Session management (seeing where you are signed in and signing other devices out) is not exposed either; device pairing stays. This is a deliberate divergence from Apple, which does surface the STATS dashboard. Do not add any of it back to menus without an explicit product decision.
- Watch Party (synchronized playback) is experimental on phone and TV. Its entry points (profile menu, the detail page's party action, and phone `prairie://watch-party` invitations) appear only while Settings โ Experimental โ Watch Party is on, a device-local toggle that defaults on in debug builds and off in release builds, and it works only when the server advertises the room and playback capabilities. Keep it behind the toggle until the qualification matrix in `docs/watch-party-implementation-plan.md` passes.
## Skills & review agents
@@ -32,7 +32,7 @@ repos are under `.agents/code-review-kit/`.
## Coding Style & Naming Conventions
-Use Kotlin 2.1, Java 21 targets, and Compose idioms. The Prairie package root is `org.prairieserver.prairie`. Both the phone and TV apps share a single `applicationId`, `org.prairieserver.prairie`, so they publish as one Google Play listing (Play routes each build by manifest feature filtering โ the phone build requires `android.hardware.touchscreen`; the TV build requires `android.software.leanback`). The Gradle `namespace` stays distinct per module (`org.prairieserver.prairie.android`, `org.prairieserver.prairie.tv`) so generated `R`/`BuildConfig` classes don't collide. The two artifacts use distinct versionCodes (`base*2` for phone, `base*2+1` for TV), so each release bumps both by 2 with no reuse. Releases derive the base from the marketing version plus a build number (`base = 100_000_000 + (major*10000 + minor*100 + patch)*1000 + build`), so the same version can ship repeatedly as build 1, 2, 3 the way TestFlight does โ bump the build number rather than inventing a new patch version for a re-release. This repo is on the full Prairie namespace cut, so do not add legacy package IDs, legacy storage names, or old-brand symbols. Kotlin classes and composables use `PascalCase`; functions and properties use `camelCase`.
+Use Kotlin 2.1, Java 21 targets, and Compose idioms. The Prairie package root is `org.prairieserver.prairie`. Both the phone and TV apps share a single `applicationId`, `org.prairieserver.prairie`, so they publish as one Google Play listing (Play routes each build by manifest feature filtering โ the phone build requires `android.hardware.touchscreen`; the TV build requires `android.software.leanback`). The Gradle `namespace` stays distinct per module (`org.prairieserver.prairie.android`, `org.prairieserver.prairie.tv`) so generated `R`/`BuildConfig` classes don't collide. The two artifacts use distinct versionCodes (`base*2` for phone, `base*2+1` for TV), so each release bumps both by 2 with no reuse. `scripts/release-version.py` derives the base from the store version and a repository-wide build number (`base = 100_000_000 + (major*10000 + minor*100 + patch)*1000 + build`), so a version ships as many tester builds as it needs before its store release. See `docs/releasing.md`. This repo is on the full Prairie namespace cut, so do not add legacy package IDs, legacy storage names, or old-brand symbols. Kotlin classes and composables use `PascalCase`; functions and properties use `camelCase`.
## Testing Guidelines
@@ -74,7 +74,10 @@ follow-up work.
covers UI and UX changes and changes to which items appear or what they show,
such as search results, home sections, recommendations, sorting, filtering,
metadata, or artwork. Use before-and-after captures of the same screen with the
- same data, and a short recording when motion, timing, or focus matters. Write
+ same data, and a short recording when motion, timing, or focus matters.
+ Capture each device the change can reach: an Android phone, an Android
+ tablet, an Android foldable (folded and open), and Android TV, or say why a
+ device is unaffected. Write
`Evidence: none, no user-visible change` only when that is true.
- Attach evidence on GitHub under the PR body's Evidence heading, or publish it
with `npx @prairie-server/evidence publish --pr ` and put its
diff --git a/FEATURES.md b/FEATURES.md
index 328911abc0..a258a602f2 100644
--- a/FEATURES.md
+++ b/FEATURES.md
@@ -10,7 +10,7 @@ A detailed inventory of what the Android **phone** and **TV** clients do today.
File pointers are repository-relative.
-> **Important exposure note:** Requests is live on both Android surfaces, gated by the server's `requests_enabled` flag (`/api/v2/requests/status`), and reached from the profile menu and search on phone and from a top-bar tab and search on TV โ matching the Apple clients. Admins who can moderate approve, decline, and retry requests there. The richer admin screens (users/sessions/logs/scans) remain inaccessible. Watch Party is experimental, behind Settings โ Experimental โ Watch Party.
+> **Important exposure note:** Requests is live on both Android surfaces, gated by the server's `requests_enabled` flag (`/api/v2/requests/status`), and reached from the profile menu and search on phone and from a top-bar tab and search on TV โ matching the Apple clients. Request administration (approve, decline, retry, limits, auto-approve) stays in web admin. The admin screens (users/sessions/logs/scans) remain inaccessible. Watch Party is experimental, behind Settings โ Experimental โ Watch Party.
---
@@ -88,7 +88,7 @@ Shown only while Settings โ Experimental โ Watch Party is on (default on in
| Release calendar | โ
| โ
| Top-level mobile tab and TV top-menu tab |
| Live home refresh (events websocket) | โ
| โ
| `HomeRealtimeCoordinator`: user_state/catalog channels, 2s debounce; TV also refreshes on resume |
| System "Watch Next" row integration | โ | โ
| `WatchNextRepository` (tvprovider) |
-| Requests | โ
| โ
| Server-gated by `requests_enabled`; phone: profile menu + search; TV: top-bar tab + search; admin approve/decline/retry |
+| Requests | โ
| โ
| Server-gated by `requests_enabled`; phone: profile menu + search; TV: top-bar tab + search; "Notify me when available" on titles someone else requested (when the server reports `follow_supported`); request administration stays in web admin |
## Reading (ebooks)
@@ -135,7 +135,7 @@ Shown only while Settings โ Experimental โ Watch Party is on (default on in
| Favorites & watchlist | โ
| โ
| TV: from Settings |
| Ratings | โ
| โ
| |
| Watch history | โ
| โ
| |
-| Content requests (browse/search TMDB, status tracking) | โ
| โ
| Four-step status track, My Requests, admin approvals; see Requests above |
+| Content requests (browse/search TMDB, status tracking) | โ
| โ
| Four-step status track, My Requests, notify me when someone else's request becomes available; see Requests above |
| Release calendar | โ
| โ
| |
| Notifications inbox (paginated, realtime updates, mark-read) | โ
| โ
| REST + WebSocket |
@@ -160,6 +160,6 @@ Shown only while Settings โ Experimental โ Watch Party is on (default on in
**TV** is a 10-foot, D-pad client focused on browsing and playback, including audiobooks, calendar, the subtitle suite, person detail, and system Watch Next integration. It intentionally omits ebooks/reading and downloads management.
-**Not currently exposed on either Android surface:** the admin stats dashboard and full admin management (users/sessions/logs/scans). **Experimental:** Watch Party, behind Settings โ Experimental. Request moderation (approve, decline, retry) is the one admin action the Requests screens offer.
+**Not currently exposed on either Android surface:** the admin stats dashboard, full admin management (users/sessions/logs/scans), and request moderation (approve, decline, retry), which stays in web admin. **Experimental:** Watch Party, behind Settings โ Experimental.
Both apps share the same networking, auth, repositories, most ViewModels, and the entire Media3 playback/capability stack.
diff --git a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/data/repository/RoomCatalogCacheRepository.kt b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/data/repository/RoomCatalogCacheRepository.kt
index cf3e2c256a..48f009a8be 100644
--- a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/data/repository/RoomCatalogCacheRepository.kt
+++ b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/data/repository/RoomCatalogCacheRepository.kt
@@ -13,6 +13,8 @@ import org.prairieserver.prairie.model.section.ResolvedSection
import org.prairieserver.prairie.network.AuthScopeSnapshot
import org.prairieserver.prairie.network.DefaultIdentityTransitionBarrier
import org.prairieserver.prairie.network.IdentityTransitionBarrier
+import org.prairieserver.prairie.repository.ArtworkSizing
+import org.prairieserver.prairie.repository.ArtworkSurface
import org.prairieserver.prairie.repository.port.CatalogCachePort
import org.prairieserver.prairie.repository.port.CatalogCacheWriteLease
import kotlinx.serialization.json.Json
@@ -21,13 +23,15 @@ import kotlinx.serialization.json.Json
* Room-backed [CatalogCachePort] (Track B). Stores each catalog read as a JSON
* blob in the generic `catalog_cache` table, scoped to the active
* `(serverId, profileId)`. Corrupt/forward-incompatible JSON decodes to null
- * rather than crashing the browse screen.
+ * rather than crashing the browse screen. Reads that carry artwork are keyed by
+ * the widths their screen draws it at.
*/
class RoomCatalogCacheRepository(
db: PrairieDatabase,
private val snapshotProvider: suspend () -> AuthScopeSnapshot?,
private val identityTransitions: IdentityTransitionBarrier = DefaultIdentityTransitionBarrier(),
private val now: () -> Long = { System.currentTimeMillis() },
+ private val artwork: ArtworkSizing = ArtworkSizing.None,
) : CatalogCachePort {
private val dao = db.catalogCacheDao()
@@ -45,8 +49,9 @@ class RoomCatalogCacheRepository(
override suspend fun cacheLibraries(libraries: List) =
cacheLibraries(libraries, currentWriteLease())
- override suspend fun cacheLibraries(libraries: List, lease: CatalogCacheWriteLease) =
+ override suspend fun cacheLibraries(libraries: List, lease: CatalogCacheWriteLease) {
put(KEY_LIBRARIES, json.encodeToString(libraries), lease)
+ }
override suspend fun getCachedLibraries(): List? =
get(KEY_LIBRARIES)?.let { runCatching { json.decodeFromString>(it) }.getOrNull() }
@@ -58,10 +63,11 @@ class RoomCatalogCacheRepository(
libraryId: Int,
response: CatalogResponse,
lease: CatalogCacheWriteLease,
- ) = put(libraryKey(libraryId), json.encodeToString(response), lease)
+ ) = putArtwork(libraryPrefix(libraryId), ArtworkSurface.BROWSE, json.encodeToString(response), lease)
override suspend fun getCachedDefaultLibraryPage(libraryId: Int): CatalogResponse? =
- get(libraryKey(libraryId))?.let { runCatching { json.decodeFromString(it) }.getOrNull() }
+ getArtwork(libraryPrefix(libraryId), ArtworkSurface.BROWSE)
+ ?.let { runCatching { json.decodeFromString(it) }.getOrNull() }
override suspend fun cacheLibrarySections(libraryId: Int, sections: List) =
cacheLibrarySections(libraryId, sections, currentWriteLease())
@@ -70,15 +76,18 @@ class RoomCatalogCacheRepository(
libraryId: Int,
sections: List,
lease: CatalogCacheWriteLease,
- ) = put(librarySectionsKey(libraryId), json.encodeToString(sections), lease)
+ ) {
+ put(librarySectionsKey(libraryId), json.encodeToString(sections), lease)
+ }
override suspend fun getCachedLibrarySections(libraryId: Int): List? =
get(librarySectionsKey(libraryId))?.let { runCatching { json.decodeFromString>(it) }.getOrNull() }
- override suspend fun cacheLibrarySectionsV2(libraryId: Int, sections: List, owner: AuthScopeSnapshot) {
+ override suspend fun cacheLibrarySectionsV2(libraryId: Int, sections: List, owner: AuthScopeSnapshot,
+ artworkFingerprint: String?) {
val profile = owner.profileId ?: return
if (owner != snapshotProvider()) return
- val key = librarySectionsV2Key(libraryId, owner)
+ val key = librarySectionsV2Key(libraryId, owner, artworkFingerprint)
val body = json.encodeToString(sections)
if (body.encodeToByteArray().size > MAX_CACHE_BYTES) {
dao.delete(owner.serverId, profile, key)
@@ -86,18 +95,25 @@ class RoomCatalogCacheRepository(
}
// Address only the captured owner's key, even if identity changes during Room's suspension.
dao.upsert(CatalogCacheEntity(owner.serverId, profile, key, body, now()))
+ val legacy = librarySectionsV2LegacyKey(libraryId, owner)
+ if (key != legacy) runCatching { dao.delete(owner.serverId, profile, legacy) }
}
override suspend fun getCachedLibrarySectionsV2(libraryId: Int, owner: AuthScopeSnapshot): List? {
val profile = owner.profileId ?: return null
if (owner != snapshotProvider()) return null
val key = librarySectionsV2Key(libraryId, owner)
- val row = runCatching { dao.get(owner.serverId, profile, key) }.getOrNull()
+ val legacy = librarySectionsV2LegacyKey(libraryId, owner)
+ val row = runCatching {
+ dao.get(owner.serverId, profile, key) ?: if (key != legacy) dao.get(owner.serverId, profile, legacy) else null
+ }.getOrNull()
if (owner != snapshotProvider()) return null
return row?.json?.let { runCatching { json.decodeFromString>(it) }.getOrNull() }
}
- private fun librarySectionsV2Key(libraryId: Int, owner: AuthScopeSnapshot): String =
+ private suspend fun librarySectionsV2Key(libraryId: Int, owner: AuthScopeSnapshot, artworkFingerprint: String? = null): String =
+ owner.identityCacheKey(artworkKey("library-sections-v2:$libraryId", ArtworkSurface.HOME, artworkFingerprint))
+ private fun librarySectionsV2LegacyKey(libraryId: Int, owner: AuthScopeSnapshot): String =
owner.identityCacheKey("library-sections-v2:$libraryId")
override suspend fun cacheItemDetail(contentId: String, detail: ItemDetail) =
@@ -107,10 +123,11 @@ class RoomCatalogCacheRepository(
contentId: String,
detail: ItemDetail,
lease: CatalogCacheWriteLease,
- ) = put(itemDetailKey(contentId), json.encodeToString(detail), lease)
+ ) = putArtwork(itemDetailPrefix(contentId), ArtworkSurface.DETAIL, json.encodeToString(detail), lease)
override suspend fun getCachedItemDetail(contentId: String): ItemDetail? =
- get(itemDetailKey(contentId))?.let { runCatching { json.decodeFromString(it) }.getOrNull() }
+ getArtwork(itemDetailPrefix(contentId), ArtworkSurface.DETAIL)
+ ?.let { runCatching { json.decodeFromString(it) }.getOrNull() }
override suspend fun cacheSeasons(seriesId: String, response: SeasonsResponse) =
cacheSeasons(seriesId, response, currentWriteLease())
@@ -119,7 +136,9 @@ class RoomCatalogCacheRepository(
seriesId: String,
response: SeasonsResponse,
lease: CatalogCacheWriteLease,
- ) = put(seasonsKey(seriesId), json.encodeToString(response), lease)
+ ) {
+ put(seasonsKey(seriesId), json.encodeToString(response), lease)
+ }
override suspend fun getCachedSeasons(seriesId: String): SeasonsResponse? =
get(seasonsKey(seriesId))?.let { runCatching { json.decodeFromString(it) }.getOrNull() }
@@ -132,26 +151,29 @@ class RoomCatalogCacheRepository(
seasonNumber: Int,
response: EpisodesResponse,
lease: CatalogCacheWriteLease,
- ) = put(episodesKey(seriesId, seasonNumber), json.encodeToString(response), lease)
+ surface: ArtworkSurface,
+ ) = putArtwork(episodesPrefix(seriesId, seasonNumber), surface, json.encodeToString(response), lease)
- override suspend fun getCachedEpisodes(seriesId: String, seasonNumber: Int): EpisodesResponse? =
- get(episodesKey(seriesId, seasonNumber))?.let { runCatching { json.decodeFromString(it) }.getOrNull() }
+ override suspend fun getCachedEpisodes(seriesId: String, seasonNumber: Int, surface: ArtworkSurface): EpisodesResponse? =
+ getArtwork(episodesPrefix(seriesId, seasonNumber), surface)
+ ?.let { runCatching { json.decodeFromString(it) }.getOrNull() }
+ /** The owner the row was written for, or null when it wasn't written. */
private suspend fun put(
cacheKey: String,
jsonStr: String,
lease: CatalogCacheWriteLease,
- ) {
- if (lease.identityGeneration != identityTransitions.generation.value) return
- val snapshot = snapshotProvider() ?: return
- val profileId = snapshot.profileId ?: return
- if (lease.identityGeneration != identityTransitions.generation.value) return
+ ): CatalogCacheScope? {
+ if (lease.identityGeneration != identityTransitions.generation.value) return null
+ val snapshot = snapshotProvider() ?: return null
+ val profileId = snapshot.profileId ?: return null
+ if (lease.identityGeneration != identityTransitions.generation.value) return null
// A Room row must fit SQLite's ~2MB CursorWindow or the *read* throws
// SQLiteBlobTooBigException. Big library pages can exceed it, so don't
// store an unreadable row โ drop any prior row for this key and skip.
if (jsonStr.encodeToByteArray().size > MAX_CACHE_BYTES) {
runCatching { dao.delete(snapshot.serverId, profileId, cacheKey) }
- return
+ return null
}
dao.upsert(
CatalogCacheEntity(
@@ -162,6 +184,7 @@ class RoomCatalogCacheRepository(
cachedAtMs = now(),
),
)
+ return CatalogCacheScope(snapshot.serverId, profileId)
}
private fun currentWriteLease() =
@@ -179,14 +202,33 @@ class RoomCatalogCacheRepository(
}?.json
}
+ // A write uses the fingerprint its read captured; a read uses the current one.
+ private suspend fun artworkKey(prefix: String, surface: ArtworkSurface, captured: String?) =
+ if (captured != null) ArtworkSizing.cacheKey(prefix, captured) else artwork.cacheKey(prefix, surface)
+
+ // Rows cached before artwork widths were keyed by the bare prefix. A read
+ // falls back to one until this item is cached again, which retires it.
+ private suspend fun getArtwork(prefix: String, surface: ArtworkSurface): String? {
+ val key = artwork.cacheKey(prefix, surface)
+ return get(key) ?: if (key != prefix) get(prefix) else null
+ }
+
+ private suspend fun putArtwork(prefix: String, surface: ArtworkSurface, jsonStr: String, lease: CatalogCacheWriteLease) {
+ val key = artworkKey(prefix, surface, lease.artworkFingerprint)
+ // Retire the legacy row of the owner the write went to, even if identity changed since.
+ val owner = put(key, jsonStr, lease) ?: return
+ if (key != prefix) runCatching { dao.delete(owner.serverId, owner.profileId, prefix) }
+ }
+
+ private fun libraryPrefix(libraryId: Int) = "library:$libraryId"
+ private fun itemDetailPrefix(contentId: String) = "item-detail:$contentId"
+ private fun episodesPrefix(seriesId: String, seasonNumber: Int) = "episodes:$seriesId:$seasonNumber"
+
private companion object {
// Stay well under SQLite's ~2MB CursorWindow.
const val MAX_CACHE_BYTES = 1_500_000
const val KEY_LIBRARIES = "libraries"
- fun libraryKey(libraryId: Int) = "library:$libraryId"
fun librarySectionsKey(libraryId: Int) = "library-sections:$libraryId"
- fun itemDetailKey(contentId: String) = "item-detail:$contentId"
fun seasonsKey(seriesId: String) = "seasons:$seriesId"
- fun episodesKey(seriesId: String, seasonNumber: Int) = "episodes:$seriesId:$seasonNumber"
}
}
diff --git a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/data/repository/RoomHomeCacheRepository.kt b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/data/repository/RoomHomeCacheRepository.kt
index c8e16d62ca..e96562b2ab 100644
--- a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/data/repository/RoomHomeCacheRepository.kt
+++ b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/data/repository/RoomHomeCacheRepository.kt
@@ -7,6 +7,8 @@ import org.prairieserver.prairie.model.section.ResolvedSection
import org.prairieserver.prairie.network.AuthScopeSnapshot
import org.prairieserver.prairie.network.DefaultIdentityTransitionBarrier
import org.prairieserver.prairie.network.IdentityTransitionBarrier
+import org.prairieserver.prairie.repository.ArtworkSizing
+import org.prairieserver.prairie.repository.ArtworkSurface
import org.prairieserver.prairie.repository.port.HomeCachePort
import org.prairieserver.prairie.repository.port.HomeCacheSnapshot
import org.prairieserver.prairie.repository.port.HomeCacheWriteLease
@@ -26,6 +28,9 @@ import kotlinx.serialization.json.Json
* The blob is encoded and decoded on [jsonDispatcher]: callers read the cache
* from the main thread, and with 100-item Home rows the blob is about 1 MB,
* which takes hundreds of milliseconds to decode on a low-end TV.
+ *
+ * The v2 key carries the artwork widths Home is drawn at, so a layout change
+ * never serves artwork sized for another.
*/
class RoomHomeCacheRepository(
private val db: PrairieDatabase,
@@ -33,6 +38,7 @@ class RoomHomeCacheRepository(
private val identityTransitions: IdentityTransitionBarrier = DefaultIdentityTransitionBarrier(),
private val now: () -> Long = { System.currentTimeMillis() },
private val jsonDispatcher: CoroutineDispatcher = Dispatchers.Default,
+ private val artwork: ArtworkSizing = ArtworkSizing.None,
) : HomeCachePort {
private val dao = db.homeCacheDao()
@@ -52,25 +58,31 @@ class RoomHomeCacheRepository(
}
}
- override suspend fun cacheHomeV2(sections: List, owner: AuthScopeSnapshot, stillCurrent: () -> Boolean) {
+ override suspend fun artworkFingerprint(): String = artwork.cacheFingerprint(ArtworkSurface.HOME)
+
+ override suspend fun cacheHomeV2(sections: List, owner: AuthScopeSnapshot, artworkFingerprint: String?,
+ stillCurrent: () -> Boolean) {
val profile = owner.profileId ?: return
// Encoding suspends, so it runs before the ownership check, not between it and the write.
val body = encode(sections)
if (owner != snapshotProvider()) return
- val key = scopedKey(owner)
+ val key = scopedKey(owner, artworkFingerprint)
+ val legacy = legacyKey(owner)
db.withTransaction {
if (!stillCurrent()) return@withTransaction
if (body.encodeToByteArray().size > MAX_CACHE_BYTES) scopedDao.delete(owner.serverId, profile, key)
else scopedDao.upsert(org.prairieserver.prairie.common.data.db.entity.CatalogCacheEntity(owner.serverId, profile, key, body, now()))
+ if (key != legacy) scopedDao.delete(owner.serverId, profile, legacy)
}
}
- override suspend fun cacheHomeV2IfAbsent(sections: List, owner: AuthScopeSnapshot, stillCurrent: () -> Boolean) {
+ override suspend fun cacheHomeV2IfAbsent(sections: List, owner: AuthScopeSnapshot, artworkFingerprint: String?,
+ stillCurrent: () -> Boolean) {
val profile = owner.profileId ?: return
val body = encode(sections)
if (body.encodeToByteArray().size > MAX_CACHE_BYTES) return
if (owner != snapshotProvider()) return
- val key = scopedKey(owner)
+ val key = scopedKey(owner, artworkFingerprint)
db.withTransaction {
if (!stillCurrent() || scopedDao.get(owner.serverId, profile, key) != null) return@withTransaction
scopedDao.upsert(org.prairieserver.prairie.common.data.db.entity.CatalogCacheEntity(owner.serverId, profile, key, body, now()))
@@ -80,13 +92,23 @@ class RoomHomeCacheRepository(
override suspend fun getCachedHomeV2(owner: AuthScopeSnapshot): HomeCacheSnapshot? {
val profile = owner.profileId ?: return null
if (owner != snapshotProvider()) return null
- val row = runCatching { scopedDao.get(owner.serverId, profile, scopedKey(owner)) }.getOrNull() ?: return null
+ val key = scopedKey(owner)
+ val legacy = legacyKey(owner)
+ val row = runCatching {
+ scopedDao.get(owner.serverId, profile, key) ?: if (key != legacy) scopedDao.get(owner.serverId, profile, legacy) else null
+ }.getOrNull() ?: return null
val sections = decode(row.json)
if (owner != snapshotProvider()) return null
return sections?.let { HomeCacheSnapshot(it, row.cachedAtMs) }
}
- private fun scopedKey(owner: AuthScopeSnapshot): String = owner.identityCacheKey("home-sections-v2")
+ // A write uses the fingerprint its read captured; a read uses the current one.
+ private suspend fun scopedKey(owner: AuthScopeSnapshot, captured: String? = null): String =
+ owner.identityCacheKey(ArtworkSizing.cacheKey("home-sections-v2", captured ?: artworkFingerprint()))
+
+ // Home cached before artwork widths was keyed without them. A read falls
+ // back to it until Home is cached again, which retires it.
+ private fun legacyKey(owner: AuthScopeSnapshot): String = owner.identityCacheKey("home-sections-v2")
override suspend fun cacheHome(sections: List) {
cacheHome(
diff --git a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/discovery/LanServerDiscovery.kt b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/discovery/LanServerDiscovery.kt
new file mode 100644
index 0000000000..15d17bc272
--- /dev/null
+++ b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/discovery/LanServerDiscovery.kt
@@ -0,0 +1,426 @@
+package org.prairieserver.prairie.common.discovery
+
+import io.ktor.client.HttpClient
+import io.ktor.client.plugins.HttpTimeout
+import io.ktor.client.request.prepareGet
+import io.ktor.client.statement.bodyAsChannel
+import io.ktor.http.isSuccess
+import io.ktor.utils.io.readRemaining
+import kotlinx.coroutines.CancellationException
+import kotlinx.coroutines.CoroutineDispatcher
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.cancel
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.isActive
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.sync.Semaphore
+import kotlinx.coroutines.sync.withPermit
+import kotlinx.coroutines.withTimeoutOrNull
+import kotlinx.io.readByteArray
+import kotlinx.serialization.json.JsonObject
+import kotlinx.serialization.json.JsonPrimitive
+import org.prairieserver.prairie.common.ui.marquee.ServerBranding
+import org.prairieserver.prairie.network.PrairieJson
+import org.prairieserver.prairie.network.api.DefaultServerIdentityApi
+import org.prairieserver.prairie.network.api.ServerIdentityProbe
+import org.prairieserver.prairie.network.createPlatformHttpClient
+
+/**
+ * One Prairie server found on the local network and confirmed by its identity.
+ * [url] is `http://:` built from the resolved address, never
+ * the advertised `.local` name.
+ */
+data class DiscoveredServer(
+ val serverId: String,
+ val name: String,
+ val url: String,
+) {
+ /** The port stays, so two deployments on one host are told apart. */
+ val detail: String get() = "On this network ยท ${ServerBranding.hostLabel(url)}"
+}
+
+/** What the platform browser resolved for one `_prairie._tcp` instance. */
+data class LanAdvertisement(
+ val instanceName: String,
+ /** Numeric addresses; an IPv6 literal may carry a `%scope`. */
+ val addresses: List,
+ val port: Int,
+ val attributes: Map,
+)
+
+/** A DNS-SD browser for `_prairie._tcp`; [NsdLanServiceBrowser] on Android. */
+interface LanServiceBrowser {
+ /** Starts browsing; [events] may be called on any thread until [stop]. */
+ fun start(events: Events)
+
+ fun stop()
+
+ /** Asks again for what one-shot resolves can't follow, such as a new DHCP lease. */
+ fun refresh()
+
+ interface Events {
+ /** [key] resolved, or its addresses, port, or TXT changed. */
+ fun resolved(key: String, advertisement: LanAdvertisement)
+
+ /** [key] is no longer reachable; it may resolve again later. */
+ fun gone(key: String)
+
+ /** Browsing could not start (multicast unavailable, for example), or stopped on its own. */
+ fun failed()
+ }
+}
+
+/**
+ * Lists the Prairie servers advertised on the local network while the setup
+ * screen is visible (prairie-server `docs/architecture/server-discovery.md`,
+ * prairie-apple `ServerDiscovery.swift`).
+ *
+ * An advertisement is a hint: its TXT `id` names a deployment, but anyone on
+ * the network can advertise anything. A result is listed only when
+ * `GET /api/v2/system/identity` at its resolved address reports that same
+ * id, and choosing it is the same as typing its address. Results with one id
+ * (replicas, or one host seen on several interfaces) share one row.
+ */
+class LanServerDiscovery(
+ private val browser: LanServiceBrowser,
+ private val probeIdentity: suspend (String) -> ServerIdentityProbe,
+ /** The server's display name from its branding, or null. */
+ private val fetchName: suspend (String) -> String?,
+ private val dispatcher: CoroutineDispatcher = Dispatchers.Main.immediate,
+) {
+ private val _servers = MutableStateFlow>(emptyList())
+ val servers: StateFlow> = _servers.asStateFlow()
+
+ /** One browse result and how far its confirmation got. */
+ private class Entry(
+ val serverId: String,
+ val origins: List,
+ val instanceName: String,
+ ) {
+ /** The last confirmed row, listed while non-null. */
+ var row: DiscoveredServer? = null
+
+ /** Failed re-checks of [row] in a row. */
+ var misses = 0
+ var job: Job? = null
+ }
+
+ private var scope: CoroutineScope? = null
+
+ /** Advances on start and stop; events from an earlier visit are dropped. */
+ private var session = 0
+
+ /** Advances whenever a browse starts; events the replaced browse already queued are dropped. */
+ private var browseGeneration = 0
+
+ /** However many results are advertised, only a few addresses are probed at once. */
+ private val probeSlots = Semaphore(MAX_CONCURRENT_PROBES)
+ private var browseFailed = false
+
+ /** One quick restart per visit after a failed browse; later ones wait for the refresh. */
+ private var quickRetryUsed = false
+
+ /** Keyed by the browser's result key; entries are replaced, never edited, when an advertisement changes. */
+ private val entries = mutableMapOf()
+
+ fun start() {
+ if (scope != null) return
+ val session = ++session
+ quickRetryUsed = false
+ val scope = CoroutineScope(SupervisorJob() + dispatcher).also { scope = it }
+ startBrowse(session, scope)
+ scope.launch {
+ while (isActive) {
+ delay(REFRESH_INTERVAL_MS)
+ refresh(session, scope)
+ }
+ }
+ }
+
+ fun stop() {
+ val scope = scope ?: return
+ this.scope = null
+ session++
+ browser.stop()
+ scope.cancel()
+ entries.clear()
+ _servers.value = emptyList()
+ }
+
+ private fun startBrowse(session: Int, scope: CoroutineScope) {
+ browseFailed = false
+ browser.start(SessionEvents(session, ++browseGeneration, scope))
+ }
+
+ /**
+ * Every [REFRESH_INTERVAL_MS]: restart a browse that failed, re-check
+ * listed rows (a server keeps its advertisement when its address changes,
+ * and a renamed server gets its new name), and give results that failed
+ * both confirmations another pair, for a server that was still starting.
+ */
+ private fun refresh(session: Int, scope: CoroutineScope) {
+ if (this.session != session) return
+ if (browseFailed) {
+ restartBrowse(session, scope)
+ return
+ }
+ browser.refresh()
+ for ((key, entry) in entries) {
+ if (entry.job?.isActive != true) confirm(key, entry, scope)
+ }
+ }
+
+ private fun restartBrowse(session: Int, scope: CoroutineScope) {
+ browser.stop()
+ clearEntries()
+ startBrowse(session, scope)
+ }
+
+ /** A browse can fail while the previous visit's is still stopping; try again shortly, once. */
+ private fun onBrowseFailed(session: Int, scope: CoroutineScope) {
+ browseFailed = true
+ if (quickRetryUsed) return
+ quickRetryUsed = true
+ scope.launch {
+ delay(BROWSE_RETRY_DELAY_MS)
+ if (this@LanServerDiscovery.session == session && browseFailed) restartBrowse(session, scope)
+ }
+ }
+
+ private fun onResolved(key: String, advertisement: LanAdvertisement, scope: CoroutineScope) {
+ val serverId = advertisedServerId(advertisement.attributes)
+ val origins = lanOrigins(advertisement.addresses, advertisement.port)
+ if (serverId == null) {
+ onGone(key)
+ return
+ }
+ // A one-shot resolve (before API 34) may return only an address a URL
+ // can't use, such as link-local IPv6; keep what the last one found.
+ if (origins.isEmpty()) return
+ val existing = entries[key]
+ if (existing != null && existing.serverId == serverId && existing.origins == origins &&
+ existing.instanceName == advertisement.instanceName
+ ) {
+ return
+ }
+ existing?.job?.cancel()
+ val entry = Entry(serverId, origins, advertisement.instanceName)
+ // A row keeps showing while its address is still advertised; a moved
+ // address or a changed id (a reinstall, or another deployment on the
+ // same name and port) is confirmed afresh before it's listed.
+ if (existing != null && existing.serverId == serverId && existing.row?.url in origins) {
+ entry.row = existing.row
+ entry.misses = existing.misses
+ }
+ entries[key] = entry
+ publish()
+ confirm(key, entry, scope)
+ }
+
+ private fun onGone(key: String) {
+ entries.remove(key)?.job?.cancel() ?: return
+ publish()
+ }
+
+ /**
+ * A new result gets two attempts a few seconds apart; a listed row gets
+ * one per refresh and stays through [MAX_MISSES] - 1 failures in a row, so
+ * a probe that times out during a network change doesn't pull it from
+ * under the person about to choose it.
+ */
+ private fun confirm(key: String, entry: Entry, scope: CoroutineScope) {
+ entry.job?.cancel()
+ entry.job = scope.launch {
+ val attempts = if (entry.row != null) 1 else MAX_ATTEMPTS
+ repeat(attempts) { attempt ->
+ if (attempt > 0) delay(RETRY_DELAY_MS)
+ val found = probeSlots.withPermit { confirmAt(entry) }
+ // Replaced or removed meanwhile: the newer entry reports.
+ if (entries[key] !== entry) return@launch
+ if (found != null) {
+ entry.row = found
+ entry.misses = 0
+ publish()
+ return@launch
+ }
+ }
+ if (entry.row != null) {
+ entry.misses++
+ if (entry.misses >= MAX_MISSES) {
+ entry.row = null
+ entry.misses = 0
+ }
+ publish()
+ }
+ }
+ }
+
+ /** The first of [Entry.origins] whose identity matches the advertised id. */
+ private suspend fun confirmAt(entry: Entry): DiscoveredServer? {
+ for (origin in entry.origins) {
+ val probe = withTimeoutOrNull(PROBE_TIMEOUT_MS) { probeIdentity(origin) }
+ if (probe !is ServerIdentityProbe.Identity || probe.serverId != entry.serverId) continue
+ val name = withTimeoutOrNull(PROBE_TIMEOUT_MS) { fetchName(origin) }?.trim()?.takeIf { it.isNotEmpty() }
+ ?: entry.instanceName.trim().takeIf { it.isNotEmpty() }
+ ?: "Prairie"
+ return DiscoveredServer(serverId = probe.serverId, name = name, url = origin)
+ }
+ return null
+ }
+
+ private fun clearEntries() {
+ entries.values.forEach { it.job?.cancel() }
+ entries.clear()
+ publish()
+ }
+
+ private fun publish() {
+ _servers.value = discoveredRows(entries.values.mapNotNull { entry -> entry.row?.let { it to entry.misses } })
+ }
+
+ /** Routes browser callbacks onto [scope], dropping any from an earlier visit or browse. */
+ private inner class SessionEvents(
+ private val session: Int,
+ private val generation: Int,
+ private val scope: CoroutineScope,
+ ) : LanServiceBrowser.Events {
+ override fun resolved(key: String, advertisement: LanAdvertisement) =
+ post { onResolved(key, advertisement, scope) }
+
+ override fun gone(key: String) = post { onGone(key) }
+
+ override fun failed() = post { onBrowseFailed(session, scope) }
+
+ private fun post(block: () -> Unit) {
+ scope.launch {
+ if (this@LanServerDiscovery.session == session && browseGeneration == generation) block()
+ }
+ }
+ }
+
+ companion object {
+ const val SERVICE_TYPE = "_prairie._tcp"
+ internal const val TXT_SERVER_ID = "id"
+
+ /** Discovery runs while someone looks at the setup screen; a dead address must give up quickly. */
+ internal const val PROBE_TIMEOUT_MS = 4_000L
+ internal const val RETRY_DELAY_MS = 3_000L
+ internal const val BROWSE_RETRY_DELAY_MS = 2_000L
+ internal const val REFRESH_INTERVAL_MS = 30_000L
+ internal const val MAX_ATTEMPTS = 2
+ internal const val MAX_MISSES = 3
+
+ /** Addresses tried per result, so one host with many interfaces stays bounded. */
+ internal const val MAX_ORIGINS = 4
+ internal const val MAX_CONCURRENT_PROBES = 4
+ }
+}
+
+/**
+ * Unauthenticated reads of a found address, on a client of their own: an
+ * address that only advertised itself must not receive the signed-in
+ * server's credentials or this device's metadata, even when it shares that
+ * server's origin. These reads run without anyone asking, so each answer is
+ * capped at [MAX_BODY_BYTES]; an advertiser can't make the app buffer more.
+ */
+class LanServerProbe {
+ private val client: HttpClient = createPlatformHttpClient(retryOnConnectionFailure = false).config {
+ // A redirect would let an advertiser point these unasked-for requests
+ // at an address [lanOrigins] refuses, such as this device's loopback.
+ followRedirects = false
+ install(HttpTimeout)
+ }
+
+ suspend fun identity(serverUrl: String): ServerIdentityProbe =
+ readObject(serverUrl, DefaultServerIdentityApi.IDENTITY_PATH)?.string("server_id")
+ ?.let { ServerIdentityProbe.Identity(it) }
+ ?: ServerIdentityProbe.Unreachable
+
+ /** `server_name` from `GET /api/v2/theme/branding`. */
+ suspend fun name(serverUrl: String): String? = readObject(serverUrl, BRANDING_PATH)?.string("server_name")
+
+ /** The JSON object [serverUrl] answers at [path], or null for anything else or anything larger. */
+ private suspend fun readObject(serverUrl: String, path: String): JsonObject? = try {
+ client.prepareGet(serverUrl.trimEnd('/') + path).execute { response ->
+ if (!response.status.isSuccess()) return@execute null
+ val body = response.bodyAsChannel().readRemaining(MAX_BODY_BYTES + 1L).readByteArray()
+ if (body.size > MAX_BODY_BYTES) return@execute null
+ PrairieJson.parseToJsonElement(body.decodeToString()) as? JsonObject
+ }
+ } catch (e: CancellationException) {
+ throw e
+ } catch (_: Exception) {
+ null
+ }
+
+ private fun JsonObject.string(key: String): String? =
+ (this[key] as? JsonPrimitive)?.takeIf { it.isString }?.content?.trim()?.takeIf { it.isNotEmpty() }
+
+ private companion object {
+ const val BRANDING_PATH = "/api/v2/theme/branding"
+
+ /** Identity and branding answers are a few hundred bytes. */
+ const val MAX_BODY_BYTES = 64 * 1024
+ }
+}
+
+/** The TXT `id`, or null when absent or blank. Every other key is ignored, including `v`. */
+internal fun advertisedServerId(attributes: Map): String? =
+ attributes.entries
+ .firstOrNull { it.key.equals(LanServerDiscovery.TXT_SERVER_ID, ignoreCase = true) }
+ ?.value
+ ?.decodeToString()
+ ?.trim()
+ ?.takeIf { it.isNotEmpty() }
+
+/**
+ * `http://` origins for a resolved advertisement in the order to try them:
+ * IPv4 first, then routable IPv6 in brackets. Link-local IPv6 is skipped
+ * because a URL host can't carry its interface scope; loopback, unspecified
+ * and multicast addresses never name a server on this network.
+ */
+internal fun lanOrigins(addresses: List, port: Int): List {
+ if (port !in 1..65535) return emptyList()
+ val literals = addresses
+ .map { it.trim().removePrefix("/").substringBefore('%') }
+ .filter { it.isNotEmpty() }
+ .distinct()
+ val ipv4 = literals.filter { Ipv4Literal.matches(it) && !isUnusableIpv4(it) }
+ val ipv6 = literals.filter { ':' in it && !isLinkLocalIpv6(it) && !isUnusableIpv6(it) }
+ return (ipv4.map { "http://$it:$port" } + ipv6.map { "http://[$it]:$port" })
+ .take(LanServerDiscovery.MAX_ORIGINS)
+}
+
+/**
+ * One row per server: of several confirmed results with one id, the one with
+ * the fewest failed re-checks, then the lowest URL. Sorted by name.
+ */
+internal fun discoveredRows(rows: List>): List =
+ rows
+ .sortedWith(compareBy> { it.second }.thenBy { it.first.url })
+ .distinctBy { it.first.serverId }
+ .map { it.first }
+ .sortedWith(compareBy(String.CASE_INSENSITIVE_ORDER) { server: DiscoveredServer -> server.name }.thenBy { it.url })
+
+private val Ipv4Literal = Regex("""^\d{1,3}(\.\d{1,3}){3}$""")
+
+private fun isUnusableIpv4(literal: String): Boolean {
+ val first = literal.substringBefore('.').toInt()
+ return first == 0 || first == 127 || first >= 224
+}
+
+private fun isUnusableIpv6(literal: String): Boolean {
+ val lower = literal.lowercase()
+ return lower == "::" || lower == "::1" || lower.startsWith("ff")
+}
+
+private fun isLinkLocalIpv6(literal: String): Boolean {
+ val head = literal.lowercase()
+ return head.startsWith("fe8") || head.startsWith("fe9") || head.startsWith("fea") || head.startsWith("feb")
+}
diff --git a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/discovery/NsdLanServiceBrowser.kt b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/discovery/NsdLanServiceBrowser.kt
new file mode 100644
index 0000000000..73bb2f9aba
--- /dev/null
+++ b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/discovery/NsdLanServiceBrowser.kt
@@ -0,0 +1,304 @@
+package org.prairieserver.prairie.common.discovery
+
+import android.content.Context
+import android.net.nsd.NsdManager
+import android.net.nsd.NsdServiceInfo
+import android.os.Build
+import android.os.Handler
+import android.os.Looper
+import android.util.Log
+import androidx.annotation.RequiresApi
+
+/**
+ * Browses `_prairie._tcp` with [NsdManager]. On API 34+ each found service is
+ * followed with a service-info callback, which reports every address and
+ * follows changes. Older releases resolve once per found service, one at a
+ * time (they reject a second concurrent resolve), and again on [refresh].
+ */
+class NsdLanServiceBrowser(context: Context) : LanServiceBrowser {
+ private val nsdManager = context.applicationContext.getSystemService(Context.NSD_SERVICE) as NsdManager
+ private val lock = Any()
+
+ /** Advances on start and stop; callbacks from an earlier browse are dropped. */
+ private var generation = 0
+ private var events: LanServiceBrowser.Events? = null
+ private var discoveryListener: NsdManager.DiscoveryListener? = null
+
+ /** What the current browse sees, by [keyOf]. */
+ private val found = mutableMapOf()
+
+ /**
+ * API 34+: one callback per followed service, oldest first. A callback
+ * stays until the browse loses its service, or until [refresh] hands its
+ * slot to a service that's waiting.
+ */
+ private val callbacks = linkedMapOf()
+
+ /** Before API 34: services waiting to resolve, and the one resolving now. */
+ private val resolveQueue = ArrayDeque()
+
+ /**
+ * Held until the platform's terminal callback, even across a stop, so a
+ * new browse doesn't start a second resolve while the old one runs.
+ */
+ private var resolving = false
+
+ /** Before API 34: retries of a resolve another resolve in this app got in the way of. */
+ private val busyRetries = mutableMapOf()
+ private val handler = Handler(Looper.getMainLooper())
+
+ override fun start(events: LanServiceBrowser.Events) {
+ synchronized(lock) {
+ if (discoveryListener != null) return
+ val gen = ++generation
+ this.events = events
+ val listener = object : NsdManager.DiscoveryListener {
+ override fun onDiscoveryStarted(serviceType: String) {
+ Log.i(TAG, "Server discovery started")
+ }
+
+ override fun onServiceFound(serviceInfo: NsdServiceInfo) {
+ if (!isPrairieServiceType(serviceInfo.serviceType)) return
+ synchronized(lock) {
+ if (gen != generation) return
+ val key = keyOf(serviceInfo)
+ if (found.put(key, serviceInfo) == null) follow(key, serviceInfo)
+ }
+ }
+
+ override fun onServiceLost(serviceInfo: NsdServiceInfo) {
+ val key = keyOf(serviceInfo)
+ val events = synchronized(lock) {
+ if (gen != generation || key !in found) return
+ found.remove(key)
+ resolveQueue.remove(key)
+ callbacks.remove(key)?.let(::unregister)
+ this@NsdLanServiceBrowser.events
+ }
+ events?.gone(key)
+ }
+
+ override fun onDiscoveryStopped(serviceType: String) {
+ Log.i(TAG, "Server discovery stopped")
+ // stop() advances the generation first, so this is a stop
+ // nobody asked for (a network or daemon reset): report it so
+ // the browse is started again.
+ val events = synchronized(lock) {
+ if (gen != generation) return
+ discoveryListener = null
+ this@NsdLanServiceBrowser.events
+ }
+ events?.failed()
+ }
+
+ override fun onStartDiscoveryFailed(serviceType: String, errorCode: Int) {
+ Log.w(TAG, "Server discovery failed to start: $errorCode")
+ val events = synchronized(lock) {
+ if (gen != generation) return
+ discoveryListener = null
+ this@NsdLanServiceBrowser.events
+ }
+ events?.failed()
+ }
+
+ override fun onStopDiscoveryFailed(serviceType: String, errorCode: Int) {
+ Log.w(TAG, "Server discovery failed to stop: $errorCode")
+ }
+ }
+ discoveryListener = listener
+ val started = runCatching {
+ nsdManager.discoverServices(LanServerDiscovery.SERVICE_TYPE, NsdManager.PROTOCOL_DNS_SD, listener)
+ }
+ if (started.isFailure) {
+ Log.w(TAG, "Server discovery could not start", started.exceptionOrNull())
+ discoveryListener = null
+ events.failed()
+ }
+ }
+ }
+
+ override fun stop() {
+ synchronized(lock) {
+ generation++
+ events = null
+ discoveryListener?.let { listener -> runCatching { nsdManager.stopServiceDiscovery(listener) } }
+ discoveryListener = null
+ callbacks.values.forEach(::unregister)
+ callbacks.clear()
+ found.clear()
+ resolveQueue.clear()
+ busyRetries.clear()
+ handler.removeCallbacksAndMessages(null)
+ }
+ }
+
+ override fun refresh() {
+ synchronized(lock) {
+ if (discoveryListener == null) return
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
+ rotateFollowed()
+ } else {
+ for (key in found.keys) if (key !in resolveQueue) resolveQueue.addLast(key)
+ resolveNext()
+ }
+ }
+ }
+
+ /**
+ * API 34+ callbacks follow changes themselves, so only services without
+ * one need work: a failed registration, or a service waiting for a slot.
+ * When more services are found than [MAX_FOLLOWED], the longest-followed
+ * give up their slots, so every service is resolved in turn. A service
+ * that gives up its slot keeps its last result. Called under [lock].
+ */
+ @RequiresApi(Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
+ private fun rotateFollowed() {
+ val waiting = found.filterKeys { it !in callbacks }
+ if (waiting.isEmpty()) return
+ val needed = waiting.size - (MAX_FOLLOWED - callbacks.size)
+ callbacks.keys.take(needed.coerceAtLeast(0)).forEach { key -> callbacks.remove(key)?.let(::unregister) }
+ waiting.forEach { (key, info) -> register(key, info) }
+ }
+
+ /** Starts following [key]. Called under [lock]. */
+ private fun follow(key: String, info: NsdServiceInfo) {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
+ register(key, info)
+ } else {
+ resolveQueue.addLast(key)
+ resolveNext()
+ }
+ }
+
+ @RequiresApi(Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
+ private fun register(key: String, info: NsdServiceInfo) {
+ // NSD limits the requests one app holds, and the app's other browsers
+ // and advertisers share that budget; the rest wait for a free slot on
+ // a later refresh.
+ if (callbacks.size >= MAX_FOLLOWED) return
+ val gen = generation
+ val callback = object : NsdManager.ServiceInfoCallback {
+ override fun onServiceInfoCallbackRegistrationFailed(errorCode: Int) {
+ Log.w(TAG, "Server resolve failed: $errorCode")
+ // Retried on the next refresh.
+ synchronized(lock) { if (callbacks[key] === this) callbacks.remove(key) }
+ }
+
+ override fun onServiceUpdated(serviceInfo: NsdServiceInfo) {
+ val events = synchronized(lock) { if (owns()) events else null } ?: return
+ events.resolved(key, serviceInfo.toAdvertisement(serviceInfo.hostAddresses.mapNotNull { it.hostAddress }))
+ }
+
+ // The service may be updated again later, so the callback stays
+ // registered until the browse loses the service or its slot moves on.
+ override fun onServiceLost() {
+ val events = synchronized(lock) { if (owns()) events else null } ?: return
+ events.gone(key)
+ }
+
+ override fun onServiceInfoCallbackUnregistered() = Unit
+
+ /** False once this callback was replaced, released, or belongs to an earlier browse. */
+ private fun owns() = gen == generation && callbacks[key] === this
+ }
+ callbacks[key] = callback
+ runCatching { nsdManager.registerServiceInfoCallback(info, Runnable::run, callback) }
+ .onFailure {
+ Log.w(TAG, "Server resolve could not start", it)
+ callbacks.remove(key)
+ }
+ }
+
+ private fun unregister(callback: NsdManager.ServiceInfoCallback) {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
+ runCatching { nsdManager.unregisterServiceInfoCallback(callback) }
+ }
+ }
+
+ /** Resolves the next queued service if none is resolving. Called under [lock]. */
+ @Suppress("DEPRECATION")
+ private fun resolveNext() {
+ if (resolving) return
+ val key = resolveQueue.removeFirstOrNull() ?: return
+ // This occurrence of the service: if the browse loses it and finds it
+ // again under the same key while this resolve runs, the answer
+ // describes the old one and is dropped.
+ val info = found[key] ?: return resolveNext()
+ val gen = generation
+ resolving = true
+ val listener = object : NsdManager.ResolveListener {
+ override fun onResolveFailed(serviceInfo: NsdServiceInfo, errorCode: Int) {
+ Log.w(TAG, "Server resolve failed: $errorCode")
+ synchronized(lock) {
+ resolving = false
+ if (errorCode == NsdManager.FAILURE_ALREADY_ACTIVE) retrySoon(key, gen)
+ resolveNext()
+ }
+ }
+
+ override fun onServiceResolved(serviceInfo: NsdServiceInfo) {
+ val events = synchronized(lock) {
+ resolving = false
+ busyRetries.remove(key)
+ resolveNext()
+ if (gen == generation && found[key] === info) events else null
+ } ?: return
+ events.resolved(key, serviceInfo.toAdvertisement(listOfNotNull(serviceInfo.host?.hostAddress)))
+ }
+ }
+ runCatching { nsdManager.resolveService(info, listener) }
+ .onFailure {
+ Log.w(TAG, "Server resolve could not start", it)
+ resolving = false
+ resolveNext()
+ }
+ }
+
+ /**
+ * Another resolve in this app (casting, pairing, or a previous visit's)
+ * held the platform's single slot; try again shortly, a few times, then
+ * leave it to the next refresh. Called under [lock].
+ */
+ private fun retrySoon(key: String, gen: Int) {
+ val tries = busyRetries.getOrElse(key) { 0 }
+ if (tries >= MAX_BUSY_RETRIES) {
+ busyRetries.remove(key)
+ return
+ }
+ busyRetries[key] = tries + 1
+ handler.postDelayed({
+ synchronized(lock) {
+ if (gen != generation || key !in found || key in resolveQueue) return@postDelayed
+ resolveQueue.addLast(key)
+ resolveNext()
+ }
+ }, BUSY_RETRY_DELAY_MS)
+ }
+
+ private fun NsdServiceInfo.toAdvertisement(addresses: List) = LanAdvertisement(
+ instanceName = serviceName.orEmpty(),
+ addresses = addresses,
+ port = port,
+ attributes = attributes.orEmpty(),
+ )
+
+ private companion object {
+ const val TAG = "ServerDiscovery"
+ const val MAX_FOLLOWED = 6
+ const val MAX_BUSY_RETRIES = 3
+ const val BUSY_RETRY_DELAY_MS = 1_000L
+
+ /**
+ * One instance can be found once per network; losing it on one
+ * network must not drop it from another.
+ */
+ fun keyOf(info: NsdServiceInfo): String {
+ val network = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) info.network else null
+ return "${info.serviceName}|${network ?: ""}"
+ }
+
+ /** Found service types carry a trailing dot on Android (`_prairie._tcp.`). */
+ fun isPrairieServiceType(serviceType: String?): Boolean =
+ serviceType?.trimEnd('.') == LanServerDiscovery.SERVICE_TYPE
+ }
+}
diff --git a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/downloads/DownloadWorker.kt b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/downloads/DownloadWorker.kt
index a56f76fa14..1409941a2a 100644
--- a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/downloads/DownloadWorker.kt
+++ b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/downloads/DownloadWorker.kt
@@ -1,5 +1,6 @@
package org.prairieserver.prairie.common.downloads
+import org.prairieserver.prairie.network.localroute.stayOnSavedAddress
import android.app.Notification
import android.app.PendingIntent
import android.content.Context
@@ -218,6 +219,9 @@ class DownloadWorker(
requireOwner()
httpClient.prepareGet("/api/v2/downloads/${downloadId.encodeURLPathPart()}/file") {
transferAuthority?.let { managedDownloadAuth(it.scope) }
+ // Downloads stay on the saved address: a transfer outlives the
+ // network it started on.
+ stayOnSavedAddress()
// Streaming download: drop the global 60s TOTAL-request timeout (it
// guillotines large files mid-transfer) and keep only a socket/idle
// timeout so a genuinely stalled connection still fails โ retry.
diff --git a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/downloads/OfflineTrackAssetFetcher.kt b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/downloads/OfflineTrackAssetFetcher.kt
index b1678f3a87..4a3bcdc5ad 100644
--- a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/downloads/OfflineTrackAssetFetcher.kt
+++ b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/downloads/OfflineTrackAssetFetcher.kt
@@ -1,5 +1,6 @@
package org.prairieserver.prairie.common.downloads
+import org.prairieserver.prairie.network.localroute.stayOnSavedAddress
import android.util.Log
import io.ktor.client.HttpClient
import io.ktor.client.plugins.HttpTimeoutConfig
@@ -101,6 +102,7 @@ internal class OfflineTrackAssetFetcher(
if (attempt > 0) delay(manifestRetryDelayMs * attempt)
try {
val response = httpClient.get("/api/v2/downloads/${downloadId.encodeURLPathPart()}/manifest") {
+ stayOnSavedAddress()
configure()
}
val status = response.status
@@ -161,6 +163,7 @@ internal class OfflineTrackAssetFetcher(
return try {
if (!directory.isDirectory && !directory.mkdirs()) throw IOException("could not create $directory")
artworkHttpClient.prepareGet(url.trim()) {
+ stayOnSavedAddress()
configure()
timeout {
requestTimeoutMillis = ARTWORK_REQUEST_TIMEOUT_MS
@@ -313,6 +316,7 @@ internal class OfflineTrackAssetFetcher(
val partial = File(target.parentFile, "${target.nameWithoutExtension}.part")
try {
httpClient.prepareGet(url) {
+ stayOnSavedAddress()
configure()
// Embedded ASS/PGS sidecars are extracted from the source on
// demand, so the first byte can take a while; keep only an idle
diff --git a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/images/PrairieImageLoader.kt b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/images/PrairieImageLoader.kt
index ae6105babd..fe8672b297 100644
--- a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/images/PrairieImageLoader.kt
+++ b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/images/PrairieImageLoader.kt
@@ -31,6 +31,8 @@ import java.io.File
* - An explicitly registered OkHttp fetcher on [PrairieOkHttp.imageClient] so
* artwork shares the API clients' warm connection pool instead of a
* service-loader-built default client.
+ * - Concurrent requests for the same URL share one download
+ * ([SharedDownloadFetcherFactory]).
* - Memory cache stays at Coil's heap-proportional default.
*
* API decoding resolves artwork against its supplying response origin before
@@ -43,7 +45,7 @@ fun buildPrairieImageLoader(context: PlatformContext, cacheDir: File): ImageLoad
.crossfade(true)
.eventListenerFactory { DiagnosticsImageEventListener() }
.components {
- add(OkHttpNetworkFetcherFactory(callFactory = { PrairieOkHttp.imageClient }))
+ add(SharedDownloadFetcherFactory(OkHttpNetworkFetcherFactory(callFactory = { PrairieOkHttp.imageClient })))
}
.diskCache {
DiskCache.Builder()
diff --git a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/images/SharedDownloadFetcherFactory.kt b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/images/SharedDownloadFetcherFactory.kt
new file mode 100644
index 0000000000..e8f6d0721a
--- /dev/null
+++ b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/images/SharedDownloadFetcherFactory.kt
@@ -0,0 +1,60 @@
+package org.prairieserver.prairie.common.images
+
+import coil3.ImageLoader
+import coil3.Uri
+import coil3.fetch.FetchResult
+import coil3.fetch.Fetcher
+import coil3.request.Options
+import kotlinx.coroutines.CompletableDeferred
+
+/**
+ * Lets concurrent requests for the same remote image share one download.
+ *
+ * Coil runs each request on its own, so a detail page whose tap-time prefetch,
+ * hero and blurred page surface all ask for the backdrop downloads that file
+ * three times at once. Wrapping the network fetcher, the first fetch for a URL
+ * goes ahead; later ones wait for it, then fetch from the disk cache it
+ * filled. If the first fetch fails or is cancelled, the waiters fetch for
+ * themselves.
+ *
+ * Coil consults its memory cache before it creates a fetcher, so a request
+ * memory can serve never waits here, and a waiter waits only for the download,
+ * not for the first request's decode.
+ */
+internal class SharedDownloadFetcherFactory(
+ private val network: Fetcher.Factory,
+) : Fetcher.Factory {
+ private val inFlight = HashMap>()
+
+ override fun create(data: Uri, options: Options, imageLoader: ImageLoader): Fetcher? {
+ val fetcher = network.create(data, options, imageLoader) ?: return null
+ // Waiting only pays off when the first download lands in the disk cache.
+ if (!options.diskCachePolicy.readEnabled || !options.diskCachePolicy.writeEnabled) return fetcher
+ val key = options.diskCacheKey ?: data.toString()
+ return object : Fetcher {
+ override suspend fun fetch(): FetchResult? = sharedFetch(key, fetcher)
+ }
+ }
+
+ private suspend fun sharedFetch(key: String, fetcher: Fetcher): FetchResult? {
+ val ticket = CompletableDeferred()
+ val leader = synchronized(inFlight) {
+ inFlight[key] ?: run {
+ inFlight[key] = ticket
+ null
+ }
+ }
+ if (leader != null) {
+ leader.await()
+ return fetcher.fetch()
+ }
+ try {
+ return fetcher.fetch()
+ } finally {
+ synchronized(inFlight) {
+ if (inFlight[key] === ticket) inFlight.remove(key)
+ }
+ ticket.complete(Unit)
+ }
+ }
+}
diff --git a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/network/ClientBuildIdentity.kt b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/network/ClientBuildIdentity.kt
index 472cca09bd..5fb3fb51d0 100644
--- a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/network/ClientBuildIdentity.kt
+++ b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/network/ClientBuildIdentity.kt
@@ -42,14 +42,13 @@ fun clientVersionLabel(version: String, rawBuildNumber: String?): String =
* (`base*2` phone, `base*2+1` TV), not CI's counter.
*
* @param buildNumber the app module's `BuildConfig.BUILD_NUMBER` โ CI's
- * per-marketing-version build counter. The Gradle default `"0"` means "not
+ * build counter, one per repository. The Gradle default `"0"` means "not
* built by CI"; prefer [reportedBuildNumber] on any path where absence is
* representable.
* @param channel the app module's `BuildConfig.RELEASE_CHANNEL` โ how the build
- * was distributed. Play's own track vocabulary where the build came from a
- * track ("internal" / "alpha" / "beta" / "production"), otherwise "sideload"
- * for an APK installed outside Play or "dev" for a local debug build. Opaque
- * to the server, which stores it as reported.
+ * was distributed: "release" for Google Play, "sideload" for an APK installed
+ * outside Play, or "dev" for a local debug build. Opaque to the server, which
+ * stores it as reported.
*/
data class PrairieClientBuildIdentity(
val buildNumber: String,
diff --git a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/network/LocalRouteStarter.kt b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/network/LocalRouteStarter.kt
new file mode 100644
index 0000000000..866687bea2
--- /dev/null
+++ b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/network/LocalRouteStarter.kt
@@ -0,0 +1,199 @@
+package org.prairieserver.prairie.common.network
+
+import android.Manifest
+import android.content.Context
+import android.net.ConnectivityManager
+import android.net.LinkProperties
+import android.net.Network
+import android.net.NetworkCapabilities
+import android.util.Log
+import androidx.annotation.RequiresPermission
+import androidx.lifecycle.DefaultLifecycleObserver
+import androidx.lifecycle.Lifecycle
+import androidx.lifecycle.LifecycleOwner
+import kotlinx.coroutines.CoroutineExceptionHandler
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.flow.distinctUntilChangedBy
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.sync.Mutex
+import kotlinx.coroutines.sync.withLock
+import org.prairieserver.prairie.model.server.ServerEntry
+import org.prairieserver.prairie.network.ServerRegistry
+import org.prairieserver.prairie.network.TokenManager
+import org.prairieserver.prairie.network.localroute.AndroidLocalRouteEnvironment
+import org.prairieserver.prairie.network.localroute.LocalRouteController
+import org.prairieserver.prairie.network.localroute.LocalRouteStore
+import org.prairieserver.prairie.network.localroute.LocalRoutes
+import org.prairieserver.prairie.network.localroute.SharedPreferencesLocalRouteStore
+
+/**
+ * Feeds [LocalRouteController] on Android, for the phone and TV apps alike:
+ * the active server and whether it is signed in, the app's foreground state,
+ * and the default network. Only Wi-Fi and Ethernet count as a local network;
+ * cellular never does, and a VPN carries the transports of the network beneath
+ * it, so a VPN over cellular does not count either.
+ */
+class LocalRouteStarter private constructor(
+ private val context: Context,
+ private val registry: ServerRegistry,
+ private val tokenManager: TokenManager,
+ private val controller: LocalRouteController,
+ private val store: LocalRouteStore,
+ private val scope: CoroutineScope,
+) : DefaultLifecycleObserver {
+
+ /** [lifecycle] is the process lifecycle (`ProcessLifecycleOwner`). Both apps declare the permission. */
+ @RequiresPermission(Manifest.permission.ACCESS_NETWORK_STATE)
+ fun start(lifecycle: Lifecycle) {
+ scope.launch {
+ // Only what the route depends on: other entry updates (a fetched
+ // name, a contract probe) must not start a new check. The profile
+ // is set on sign-in and dropped on sign-out.
+ registry.activeEntry
+ .distinctUntilChangedBy { it.routeKey() }
+ .collect { publishActiveServer() }
+ }
+ scope.launch {
+ // A removed server's LAN address and key go with it. An unreadable
+ // registry looks empty, so it prunes nothing.
+ registry.entries.collect { entries ->
+ if (!registry.stateLoadFailed) store.retainOnly(entries.mapTo(mutableSetOf()) { it.id })
+ }
+ }
+ scope.launch {
+ // The generation only: the LAN address stays out of the logs.
+ LocalRoutes.state.active.collect { route ->
+ Log.i(TAG, if (route != null) "local route active (generation ${route.generation})" else "local route not in use")
+ }
+ }
+ lifecycle.addObserver(this)
+ val connectivity = context.getSystemService(ConnectivityManager::class.java)
+ if (connectivity == null) {
+ Log.w(TAG, "No ConnectivityManager; the local route stays off")
+ return
+ }
+ runCatching { connectivity.registerDefaultNetworkCallback(DefaultNetworkCallback()) }
+ .onFailure { Log.w(TAG, "registerDefaultNetworkCallback failed; the local route stays off", it) }
+ }
+
+ // Foreground changes reach the controller in the order they happen; the
+ // signed-in check that follows only refreshes the active server.
+ override fun onStart(owner: LifecycleOwner) {
+ controller.foreground(true)
+ scope.launch { publishActiveServer() }
+ }
+
+ override fun onStop(owner: LifecycleOwner) {
+ controller.foreground(false)
+ }
+
+ /**
+ * Tells the controller about the active server as it is now. One at a time,
+ * reading the entry inside the lock, so an older answer never lands after a
+ * newer one.
+ */
+ private suspend fun publishActiveServer() {
+ updates.withLock {
+ val entry = registry.activeEntry.value
+ val signedIn = isSignedIn(entry)
+ // A switch or sign-out during the lookup publishes itself next.
+ if (registry.activeEntry.value.routeKey() != entry.routeKey()) return@withLock
+ controller.update(entry, signedIn)
+ }
+ }
+
+ private val updates = Mutex()
+
+ /** The entry fields the route depends on. */
+ private fun ServerEntry?.routeKey(): List? = this?.let { listOf(it.id, it.url, it.verifiedServerId, it.profileId) }
+
+ private suspend fun isSignedIn(entry: ServerEntry?): Boolean =
+ entry != null && !tokenManager.getAccessTokenForScope(entry.id).isNullOrBlank()
+
+ /**
+ * Reports a network change only when the default network, its local
+ * eligibility, its interface or its gateways change, not on every signal
+ * strength update.
+ */
+ private inner class DefaultNetworkCallback : ConnectivityManager.NetworkCallback() {
+ private var network: Network? = null
+ private var capabilities: NetworkCapabilities? = null
+ private var link: LinkProperties? = null
+ private var signature: String? = null
+
+ override fun onAvailable(network: Network) {
+ if (network != this.network) {
+ capabilities = null
+ link = null
+ }
+ this.network = network
+ publish()
+ }
+
+ override fun onCapabilitiesChanged(network: Network, networkCapabilities: NetworkCapabilities) {
+ this.network = network
+ capabilities = networkCapabilities
+ publish()
+ }
+
+ override fun onLinkPropertiesChanged(network: Network, linkProperties: LinkProperties) {
+ this.network = network
+ link = linkProperties
+ publish()
+ }
+
+ override fun onLost(network: Network) {
+ if (network != this.network) return
+ this.network = null
+ capabilities = null
+ link = null
+ publish()
+ }
+
+ private fun publish() {
+ val allowsLocal = capabilities?.allowsLocalRoute() == true
+ val gateways = link?.routes.orEmpty()
+ .mapNotNull { route -> route.gateway?.takeUnless { it.isAnyLocalAddress }?.hostAddress }
+ .sorted()
+ val next = listOf(network?.toString(), allowsLocal, link?.interfaceName, gateways).joinToString("|")
+ if (next == signature) return
+ signature = next
+ controller.networkChanged(allowsLocal)
+ }
+ }
+
+ companion object {
+ private const val TAG = "LocalRouteStarter"
+
+ /** A failure here costs the route, never the app: requests stay on the saved address. */
+ private val logFailures = CoroutineExceptionHandler { _, failure ->
+ // Class name only: the message can carry the server's address.
+ Log.w(TAG, "local route check failed: ${failure::class.simpleName}")
+ }
+
+ /** The app's starter, driving the process-wide route ([LocalRoutes]). */
+ fun create(context: Context, registry: ServerRegistry, tokenManager: TokenManager): LocalRouteStarter {
+ val store = SharedPreferencesLocalRouteStore(context.applicationContext)
+ val controller = LocalRouteController(
+ state = LocalRoutes.state,
+ environment = AndroidLocalRouteEnvironment(tokenManager = tokenManager, store = store),
+ // One coroutine at a time: the controller's state is confined to it.
+ scope = CoroutineScope(SupervisorJob() + Dispatchers.Default.limitedParallelism(1) + logFailures),
+ )
+ return LocalRouteStarter(
+ context = context.applicationContext,
+ registry = registry,
+ tokenManager = tokenManager,
+ controller = controller,
+ store = store,
+ scope = CoroutineScope(SupervisorJob() + Dispatchers.Default + logFailures),
+ )
+ }
+
+ private fun NetworkCapabilities.allowsLocalRoute(): Boolean =
+ !hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) &&
+ (hasTransport(NetworkCapabilities.TRANSPORT_WIFI) || hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET))
+ }
+}
diff --git a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/PlaybackNetworkMonitor.kt b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/PlaybackNetworkMonitor.kt
index f372607f1d..e168621887 100644
--- a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/PlaybackNetworkMonitor.kt
+++ b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/PlaybackNetworkMonitor.kt
@@ -9,16 +9,28 @@ import android.os.Looper
import androidx.media3.common.util.UnstableApi
import androidx.media3.exoplayer.upstream.BandwidthMeter
import androidx.media3.exoplayer.upstream.DefaultBandwidthMeter
+import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.flowOf
data class PlaybackNetworkSnapshot(
val connected: Boolean = false,
val validated: Boolean = false,
+ /** The network lacks NET_CAPABILITY_NOT_METERED. */
val metered: Boolean = false,
+ /** The most specific transport, for diagnostics; a VPN reads as what it runs over. */
val transport: String = "none",
- /** Media3 throughput estimate from completed media transfers, not link speed. */
+ /** The network has TRANSPORT_CELLULAR, including a VPN running over mobile data. */
+ val cellular: Boolean = false,
+ /** The network has TRANSPORT_VPN. */
+ val vpn: Boolean = false,
+ /**
+ * Media3's throughput estimate from completed transfers, for diagnostics
+ * only: it starts as a guess from the network type, and loads that stall
+ * on a full buffer understate the link. Playback requests never carry it.
+ */
val bandwidthEstimateKbps: Int? = null,
/** OS-reported link capacity, useful only as diagnostics. */
val linkDownstreamKbps: Int? = null,
@@ -27,16 +39,22 @@ data class PlaybackNetworkSnapshot(
fun interface PlaybackNetworkEvidenceProvider {
fun snapshot(): PlaybackNetworkSnapshot
+ /** The current snapshot, then each change. */
+ val updates: Flow
+ get() = flowOf(snapshot())
+
companion object {
val None = PlaybackNetworkEvidenceProvider { PlaybackNetworkSnapshot() }
}
}
/**
- * Process-wide network evidence shared by route planning and Media3.
+ * Process-wide network evidence for the phone's cellular quality choice and
+ * for diagnostics (route events and the diagnostics bundle). Playback requests
+ * carry no bandwidth estimate.
*
* A measured estimate is cleared when Android changes the default network so
- * Wi-Fi throughput cannot leak into a cellular/VPN route decision. We do not
+ * Wi-Fi throughput is not reported against a cellular/VPN route. We do not
* substitute `linkDownstreamBandwidthKbps` for measured throughput: Android's
* link figure is a nominal interface capacity and is often wildly optimistic.
*/
@@ -72,6 +90,8 @@ class PlaybackNetworkMonitor(
override fun snapshot(): PlaybackNetworkSnapshot = _state.value
+ override val updates: Flow get() = state
+
@Synchronized
override fun onBandwidthSample(elapsedMs: Int, bytesTransferred: Long, bitrateEstimate: Long) {
if (elapsedMs <= 0 || bytesTransferred <= 0L || bitrateEstimate <= 0L) return
@@ -92,25 +112,9 @@ class PlaybackNetworkMonitor(
}
private fun publish(network: Network?, caps: NetworkCapabilities?) {
- val transport = caps.transportName()
- val metered = caps != null &&
- !caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_METERED)
- // Media3's estimate is throughput over completed media transfers โ and a
- // progressive direct play drains its socket at the file's bitrate once
- // the buffer fills, so after one low-bitrate title the meter honestly
- // reports a "3 Mbps link" on gigabit ethernet. The server folds this
- // number into auto-quality rung selection, which pinned LAN transcodes
- // at 480p. On un-metered ethernet/wifi we therefore withhold the
- // estimate (device-limit rung applies); metered and cellular/VPN routes
- // keep it, where a conservative bias protects data budgets.
- val suppressEstimate = !metered && (transport == "ethernet" || transport == "wifi")
- _state.value = PlaybackNetworkSnapshot(
- connected = network != null && caps != null,
- validated = caps?.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED) == true,
- metered = metered,
- transport = transport,
- bandwidthEstimateKbps = measuredBitrateBps?.toKbpsInt()?.takeUnless { suppressEstimate },
- linkDownstreamKbps = caps?.linkDownstreamBandwidthKbps?.takeIf { it > 0 },
+ _state.value = playbackNetworkSnapshot(
+ caps = caps.takeIf { network != null },
+ bandwidthEstimateKbps = measuredBitrateBps?.toKbpsInt(),
)
}
@@ -118,6 +122,21 @@ class PlaybackNetworkMonitor(
network?.let(connectivity::getNetworkCapabilities)
}
+/** The snapshot for the default network's [caps]; null caps is no connection. */
+internal fun playbackNetworkSnapshot(
+ caps: NetworkCapabilities?,
+ bandwidthEstimateKbps: Int? = null,
+): PlaybackNetworkSnapshot = PlaybackNetworkSnapshot(
+ connected = caps != null,
+ validated = caps?.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED) == true,
+ metered = caps != null && !caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_METERED),
+ transport = caps.transportName(),
+ cellular = caps?.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) == true,
+ vpn = caps?.hasTransport(NetworkCapabilities.TRANSPORT_VPN) == true,
+ bandwidthEstimateKbps = bandwidthEstimateKbps,
+ linkDownstreamKbps = caps?.linkDownstreamBandwidthKbps?.takeIf { it > 0 },
+)
+
private fun NetworkCapabilities?.transportName(): String = when {
this == null -> "none"
hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET) -> "ethernet"
diff --git a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/PlaybackQualityPair.kt b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/PlaybackQualityPair.kt
new file mode 100644
index 0000000000..5b231cf881
--- /dev/null
+++ b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/PlaybackQualityPair.kt
@@ -0,0 +1,145 @@
+package org.prairieserver.prairie.common.player
+
+import org.prairieserver.prairie.common.diagnostics.PrairieLog
+import org.prairieserver.prairie.common.settings.PlayerSettingsStore
+import org.prairieserver.prairie.model.diagnostics.DiagnosticsLogCategory
+import org.prairieserver.prairie.model.playback.PLAYBACK_QUALITY_AUTO
+import org.prairieserver.prairie.model.playback.videoQualityPreference
+import kotlinx.coroutines.CancellationException
+import kotlinx.coroutines.FlowPreview
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.collectLatest
+import kotlinx.coroutines.flow.debounce
+import kotlinx.coroutines.flow.distinctUntilChanged
+import kotlinx.coroutines.flow.filter
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.flow.onEach
+
+/**
+ * The two quality fields a video request carries: `quality_preference` and
+ * `bandwidth_cap_kbps` (null is uncapped).
+ */
+data class PlaybackQualityPair(val qualityPreference: String, val bandwidthCapKbps: Int?)
+
+/**
+ * Whether the phone plays at its cellular quality on this network: mobile
+ * data (TRANSPORT_CELLULAR, also through a VPN that runs over it), or a
+ * non-VPN network Android marks metered (no NET_CAPABILITY_NOT_METERED), such
+ * as a phone hotspot or Wi-Fi the user marked metered. A VPN's own metered
+ * flag is ignored: Android marks most VPNs metered by default, which would
+ * put a home Wi-Fi behind Tailscale on the cellular pair.
+ */
+val PlaybackNetworkSnapshot.usesCellularQuality: Boolean
+ get() = connected && (cellular || (!vpn && metered))
+
+/** The stored pair for a network: [cellular] picks the cellular pair over the main one. */
+suspend fun PlayerSettingsStore.qualityPair(cellular: Boolean): PlaybackQualityPair =
+ if (cellular) {
+ PlaybackQualityPair(cellularPreferredQualityFlow.first(), cellularMaxBitrateKbpsFlow.first())
+ } else {
+ PlaybackQualityPair(preferredQualityFlow.first(), maxBitrateKbpsFlow.first())
+ }
+
+/**
+ * The pair an Auto picked in the player sends: "auto" with the saved bitrate
+ * cap for the connection ([cellular] picks the cellular cap; null is No
+ * limit), so going back to Auto never lifts that cap. Null for any other
+ * pick, which keeps the session's cap.
+ */
+suspend fun PlayerSettingsStore.inPlayerQualityPair(preference: String?, cellular: Boolean): PlaybackQualityPair? {
+ if (videoQualityPreference(preference) != PLAYBACK_QUALITY_AUTO) return null
+ return autoQualityPair(cellular)
+}
+
+private suspend fun PlayerSettingsStore.autoQualityPair(cellular: Boolean): PlaybackQualityPair =
+ PlaybackQualityPair(PLAYBACK_QUALITY_AUTO, qualityPair(cellular).bandwidthCapKbps)
+
+/**
+ * A session the network follower moves: which one it is ([sessionId]), the
+ * [pair] it plays at, and whether the viewer picked Auto in the player. Auto
+ * is not a fixed pick, it means "follow my settings for this connection", so
+ * then the quality stays "auto" and only the cap follows the connection.
+ */
+data class FollowedSession(
+ val sessionId: String,
+ val pair: PlaybackQualityPair,
+ val autoPicked: Boolean = false,
+)
+
+/**
+ * Moves phone playback to the quality pair of the network it is on when that
+ * changes between unmetered and cellular or metered mid-playback.
+ *
+ * A change applies only once it has held for [settleMs], so a brief Wi-Fi
+ * drop does not cost two replans; moments without a connection are skipped
+ * rather than read as "not cellular".
+ */
+class NetworkQualityFollower(
+ private val network: Flow,
+ private val settings: PlayerSettingsStore,
+ private val settleMs: Long = DEFAULT_SETTLE_MS,
+) {
+ /**
+ * Collects until cancelled. For each settled network, [playing] reports
+ * the session, or null when it does not follow the settings: none is
+ * active, it plays a download, it is in a Watch Party, or the viewer
+ * picked a fixed rung in the player. When its pair differs from the
+ * network's, [replan] is asked to move that session to the pair and
+ * returns once the move has finished or was refused. The settings read
+ * suspends, so [replan] must refuse when playback is no longer the
+ * session it is handed.
+ *
+ * A move that did not take (the request failed, or another change was
+ * in flight) is tried again after each of [RETRY_DELAYS_MS], reading the
+ * session and settings afresh, until the session plays the network's
+ * pair, stops following, or another network supersedes it, settled or
+ * not. A settings read that fails waits for the next try the same way,
+ * so it never ends the following.
+ */
+ @OptIn(FlowPreview::class)
+ suspend fun follow(
+ playing: () -> FollowedSession?,
+ replan: suspend (FollowedSession, PlaybackQualityPair) -> Unit,
+ ) {
+ val latestCellular = MutableStateFlow(null)
+ network
+ .filter { it.connected }
+ .map { it.usesCellularQuality }
+ .distinctUntilChanged()
+ .onEach { latestCellular.value = it }
+ .debounce(settleMs)
+ .collectLatest { cellular ->
+ for (wait in RETRY_DELAYS_MS) {
+ delay(wait)
+ if (latestCellular.value != cellular) return@collectLatest
+ val current = playing() ?: return@collectLatest
+ val wanted = try {
+ if (current.autoPicked) settings.autoQualityPair(cellular) else settings.qualityPair(cellular)
+ } catch (cancellation: CancellationException) {
+ throw cancellation
+ } catch (failure: Exception) {
+ PrairieLog.w(
+ DiagnosticsLogCategory.PLAYBACK,
+ TAG,
+ "quality settings read failed",
+ throwable = failure,
+ )
+ continue
+ }
+ if (wanted == current.pair) return@collectLatest
+ replan(current, wanted)
+ }
+ }
+ }
+
+ companion object {
+ private const val TAG = "NetworkQualityFollower"
+ const val DEFAULT_SETTLE_MS = 5_000L
+
+ /** The wait before each try at a move: at once, then three retries over 50 seconds. */
+ val RETRY_DELAYS_MS = listOf(0L, 5_000L, 15_000L, 30_000L)
+ }
+}
diff --git a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/PlaybackSessionLifecycle.kt b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/PlaybackSessionLifecycle.kt
index 3aa93c8983..43e39fea10 100644
--- a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/PlaybackSessionLifecycle.kt
+++ b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/PlaybackSessionLifecycle.kt
@@ -72,6 +72,14 @@ class PlaybackSessionLifecycle(
private val _missingSessionEvents = MutableSharedFlow(extraBufferCapacity = 1)
val missingSessionEvents: SharedFlow = _missingSessionEvents.asSharedFlow()
+ /**
+ * Session ids the server ended for good (410 `playback_session_ended`):
+ * an administrator stopped the session while this player could not be told
+ * over its control socket. The owner leaves playback instead of renewing.
+ */
+ private val _sessionEndedEvents = MutableSharedFlow(extraBufferCapacity = 1)
+ val sessionEndedEvents: SharedFlow = _sessionEndedEvents.asSharedFlow()
+
/**
* Mutex protects the small set of mutable transitions we make from
* different coroutine paths (start, recovery, outage). State transitions
@@ -692,6 +700,11 @@ class PlaybackSessionLifecycle(
// params. Left unguarded, a late answer about episode A does
// all of that to episode B.
if (!ownsProgressReply(sess.sessionId)) continue
+ if (result.isPlaybackSessionEnded()) {
+ Log.i(TAG, "reportProgress: the server ended this session; leaving playback")
+ _sessionEndedEvents.tryEmit(sess.sessionId)
+ continue
+ }
if (sessionManager.isSequenced(sess.sessionId)) {
// The journal resends the pending sample on the next tick,
// so one failed tick loses nothing. Warn only once failures
@@ -1010,3 +1023,11 @@ data class StartParams(
/** Watch Party starts pin the file (false); renewals must keep that. */
val allowAlternateVersions: Boolean? = null,
)
+
+/**
+ * Whether the server ended the playback session for good: 410
+ * `playback_session_ended`, its answer once an administrator stopped a player
+ * that could not be told directly. Renewing would undo that stop.
+ */
+fun ApiResult<*>.isPlaybackSessionEnded(): Boolean =
+ this is ApiResult.Error && code == 410 && error == "playback_session_ended"
diff --git a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/PlaybackSessionManager.kt b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/PlaybackSessionManager.kt
index c17e0d5481..7cb65ec55a 100644
--- a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/PlaybackSessionManager.kt
+++ b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/PlaybackSessionManager.kt
@@ -26,6 +26,7 @@ import org.prairieserver.prairie.model.playback.SEEK_REANCHOR_V3_FEATURE
import org.prairieserver.prairie.model.playback.SEEK_REANCHOR_V3_OPERATION
import org.prairieserver.prairie.model.playback.TRACK_CHANGE_V3_OPERATION
import org.prairieserver.prairie.model.playback.playbackClientFeaturesV3
+import org.prairieserver.prairie.model.playback.SUBTITLE_SELECTION_FAILURE_CLASSIFICATIONS
import org.prairieserver.prairie.network.ApiResult
import org.prairieserver.prairie.network.TokenManager
import org.prairieserver.prairie.network.AuthScopeSnapshot
@@ -134,7 +135,6 @@ open class PlaybackSessionManager(
* the limit for the rest of the session.
*/
val bandwidthCapKbps: Int?,
- val networkEvidence: PlaybackNetworkSnapshot,
val sessionId: String,
val plan: PlaybackPlanV3,
val serverFeatures: Set,
@@ -299,6 +299,12 @@ open class PlaybackSessionManager(
expectedMetadataOwner: AuthScopeSnapshot? = null,
/** False pins [fileId] through every replan (Watch Party). Null keeps the server default. */
allowAlternateVersions: Boolean? = null,
+ /**
+ * Whether admission may take the local route: pass what the stream's
+ * media gets, since the server caps the session for the network it
+ * was admitted through. Replans keep that cap.
+ */
+ allowsLocalRoute: Boolean = false,
): ApiResult = contentStartMutex.withLock {
if (!tokenManager.acceptsMetadataOwner(expectedMetadataOwner, profileId))
return@withLock ApiResult.Error(0, "identity_changed", "The metadata viewer changed before playback admission.")
@@ -372,8 +378,6 @@ open class PlaybackSessionManager(
// the track id above already filter negatives the same way.
subtitleTrackIndex = subtitleTrackIndex?.takeIf { it >= 0 },
clientFeatures = playbackClientFeaturesV3(clientPlaybackContext),
- metered = network.metered,
- bandwidthEstimateKbps = network.bandwidthEstimateKbps,
bandwidthCapKbps = maxBitrateKbps?.takeIf { it > 0 },
capabilities = capabilities,
clientPlaybackContext = clientPlaybackContext,
@@ -381,7 +385,7 @@ open class PlaybackSessionManager(
)
if (!tokenManager.acceptsMetadataOwner(expectedMetadataOwner, profileId))
return@withLock ApiResult.Error(0, "identity_changed", "The metadata viewer changed before playback admission.")
- return@withLock when (val result = playbackRepository.startPlaybackV3(request, expectedMetadataOwner)) {
+ return@withLock when (val result = playbackRepository.startPlaybackV3(request, expectedMetadataOwner, allowsLocalRoute)) {
is ApiResult.Success -> when (val validated = result.data.validateForMedia3()) {
is PlaybackV3Validation.Playable -> {
leasedSessionId = validated.sessionId
@@ -390,7 +394,6 @@ open class PlaybackSessionManager(
val planAttemptId = UUID.randomUUID().toString()
val active = newActiveAttempt(
request = request,
- network = network,
sessionId = validated.sessionId,
plan = validated.plan,
serverFeatures = result.data.serverFeatures.toSet(),
@@ -471,7 +474,6 @@ open class PlaybackSessionManager(
val planAttemptId = UUID.randomUUID().toString()
val active = newActiveAttempt(
request = request,
- network = network,
sessionId = validated.sessionId,
plan = validated.plan,
serverFeatures = result.data.serverFeatures.toSet(),
@@ -571,7 +573,6 @@ open class PlaybackSessionManager(
private fun newActiveAttempt(
request: PlaybackStartRequestV3,
- network: PlaybackNetworkSnapshot,
sessionId: String,
plan: PlaybackPlanV3,
serverFeatures: Set,
@@ -588,7 +589,6 @@ open class PlaybackSessionManager(
playbackAttemptId = request.playbackAttemptId,
qualityPreference = request.qualityPreference,
bandwidthCapKbps = request.bandwidthCapKbps,
- networkEvidence = network,
sessionId = sessionId,
plan = plan,
serverFeatures = serverFeatures,
@@ -719,6 +719,11 @@ open class PlaybackSessionManager(
internal fun activeSessionIdForTest(): String? = activeVideoAttempt.get()?.sessionId
+ /** The quality pair the active video attempt plays at, or null with none active. */
+ fun activeVideoQualityPair(): PlaybackQualityPair? = activeVideoAttempt.get()?.let {
+ PlaybackQualityPair(it.qualityPreference, it.bandwidthCapKbps)
+ }
+
internal suspend fun orphanedSessionIdsForTest(): Set =
videoAttemptMutex.withLock { orphanedSessionIds.toSet() }
@@ -733,6 +738,13 @@ open class PlaybackSessionManager(
qualityPreference: String? = null,
capabilities: ClientCodecCapabilities? = null,
clientPlaybackContext: ClientPlaybackContext? = null,
+ /**
+ * Replaces both the quality preference and the bandwidth cap, for this
+ * request and every later one in the session: the phone moving between
+ * its main and cellular pair, or an Auto picked in the player taking
+ * the saved cap for the connection. Null keeps the session's.
+ */
+ qualityPair: PlaybackQualityPair? = null,
): ApiResult = immediateVideoReplanMutex.withLock {
when (
val prepared = prepareActiveVideoSessionReplan(
@@ -746,10 +758,11 @@ open class PlaybackSessionManager(
qualityPreference = qualityPreference,
capabilities = capabilities,
clientPlaybackContext = clientPlaybackContext,
+ qualityPair = qualityPair,
operation = replanOperationForClassification(classification),
// A rejected subtitle retry must not tear down healthy video.
// Reuse staged rejection cleanup while successful retries still commit.
- preserveImmediateOutcomes = classification != "subtitle_embedded_failed",
+ preserveImmediateOutcomes = classification !in SUBTITLE_SELECTION_FAILURE_CLASSIFICATIONS,
)
) {
is ApiResult.Success -> when (val value = prepared.data) {
@@ -772,6 +785,8 @@ open class PlaybackSessionManager(
qualityPreference: String? = null,
capabilities: ClientCodecCapabilities? = null,
clientPlaybackContext: ClientPlaybackContext? = null,
+ /** Replaces quality and cap as on [replanActiveVideoSession]: an in-player Auto. */
+ qualityPair: PlaybackQualityPair? = null,
): ApiResult = when (
val prepared = prepareActiveVideoSessionReplan(
classification = classification,
@@ -786,6 +801,7 @@ open class PlaybackSessionManager(
clientPlaybackContext = clientPlaybackContext,
operation = replanOperationForClassification(classification),
preserveImmediateOutcomes = false,
+ qualityPair = qualityPair,
)
) {
is ApiResult.Success -> when (val value = prepared.data) {
@@ -813,6 +829,7 @@ open class PlaybackSessionManager(
clientPlaybackContext: ClientPlaybackContext? = null,
operation: String,
preserveImmediateOutcomes: Boolean,
+ qualityPair: PlaybackQualityPair? = null,
): ApiResult = withSettledVideoAttempt {
if (contentResetInProgress) {
return@withSettledVideoAttempt ApiResult.Error(
@@ -834,7 +851,8 @@ open class PlaybackSessionManager(
)
}
val intent = operation in INTENT_V3_OPERATIONS
- val effectiveQuality = qualityPreference?.lowercase() ?: active.qualityPreference
+ val effectiveQuality = (qualityPair?.qualityPreference ?: qualityPreference)?.lowercase()
+ ?: active.qualityPreference
// Mirror the server's own validator so a malformed operation is caught
// before it costs a round trip: failure recovery must name what failed,
// and a quality change must name the rung it wants โ an empty
@@ -924,12 +942,14 @@ open class PlaybackSessionManager(
attemptCount = requestAttemptCount,
qualityPreference = effectiveQuality,
positionSeconds = positionSeconds,
- metered = network.metered,
- bandwidthEstimateKbps = network.bandwidthEstimateKbps,
// The cap is a per-request delivery ceiling: omitting it on a
// replan would silently lift the user's bandwidth limit for the
- // rest of the session.
- bandwidthCapKbps = active.bandwidthCapKbps,
+ // rest of the session. A new pair replaces it from here on.
+ bandwidthCapKbps = if (qualityPair != null) {
+ qualityPair.bandwidthCapKbps?.takeIf { it > 0 }
+ } else {
+ active.bandwidthCapKbps
+ },
selectedTracks = SelectedPlaybackTracksV3(
audio = selectedTrackIdentity(active, "audio", audioTrackIndex, active.plan.selectedTracks.audio),
subtitle = subtitleTrackIndex?.takeIf { it >= 0 }
@@ -1032,7 +1052,7 @@ open class PlaybackSessionManager(
attemptedPlanKeys = attemptedKeys + nextKey,
attemptCount = candidateAttemptCount,
qualityPreference = request.qualityPreference,
- networkEvidence = network,
+ bandwidthCapKbps = request.bandwidthCapKbps,
capabilities = currentCapabilities,
context = currentContext,
startedAtElapsedRealtimeMs = SystemClock.elapsedRealtime(),
@@ -1756,7 +1776,8 @@ open class PlaybackSessionManager(
return if (selected == null &&
subtitle.mode == PlaybackSubtitleModeV3.OFF &&
subtitle.trackId == null &&
- subtitle.artifact == null
+ subtitle.artifact == null &&
+ subtitle.hlsRendition == null
) {
null
} else {
@@ -1787,6 +1808,11 @@ open class PlaybackSessionManager(
if (subtitle.embedded != null && candidate.delivery == org.prairieserver.prairie.model.playback.PlaybackDelivery.ORIGINAL_HTTP &&
subtitle.embedded?.containerTrackId != null && subtitle.artifact == null
) return null
+ // The transcode carries the track in its own playlist; there is
+ // no artifact to check, and validateForMedia3 vetted the shape.
+ if (subtitle.hlsRendition != null && candidate.delivery == org.prairieserver.prairie.model.playback.PlaybackDelivery.SERVER_TRANSCODE_HLS &&
+ subtitle.artifact == null
+ ) return null
val artifact = subtitle.artifact
if (artifact == null ||
artifact.url.isBlank() ||
@@ -1862,8 +1888,6 @@ open class PlaybackSessionManager(
attemptCount = active.attemptCount,
qualityPreference = active.qualityPreference,
positionSeconds = positionSeconds,
- metered = active.networkEvidence.metered,
- bandwidthEstimateKbps = active.networkEvidence.bandwidthEstimateKbps,
bandwidthCapKbps = active.bandwidthCapKbps,
selectedTracks = active.plan.selectedTracks,
capabilities = active.capabilities,
@@ -2021,6 +2045,7 @@ open class PlaybackSessionManager(
subtitle.trackId == current.subtitle.trackId &&
subtitle.artifact?.mimeType == current.subtitle.artifact?.mimeType &&
subtitle.artifact?.format == current.subtitle.artifact?.format &&
+ subtitle.hlsRendition == current.subtitle.hlsRendition &&
subtitleFidelityPolicy == current.subtitleFidelityPolicy &&
transformations.toSet() == current.transformations.toSet() &&
appliedQuirks.toSet() == current.appliedQuirks.toSet() &&
@@ -2139,11 +2164,6 @@ open class PlaybackSessionManager(
attemptCount = requestAttemptCount,
qualityPreference = active.qualityPreference,
positionSeconds = positionSeconds,
- // Fresh snapshot, matching replanActiveVideoSession: this path lets
- // the server pick a different route, so session-start network
- // evidence would misinform that decision.
- metered = network.metered,
- bandwidthEstimateKbps = network.bandwidthEstimateKbps,
bandwidthCapKbps = active.bandwidthCapKbps,
selectedTracks = active.plan.selectedTracks,
failure = PlaybackFailureV3(
diff --git a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/PlaybackV3Session.kt b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/PlaybackV3Session.kt
index 69c8d36cba..a144a2cc8a 100644
--- a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/PlaybackV3Session.kt
+++ b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/PlaybackV3Session.kt
@@ -13,6 +13,7 @@ import org.prairieserver.prairie.model.playback.PlaybackSubtitleModeV3
import org.prairieserver.prairie.model.playback.PlaybackTimeline
import org.prairieserver.prairie.model.playback.PlayerSubtitleInfo
import org.prairieserver.prairie.model.playback.SelectedPlaybackTracks
+import org.prairieserver.prairie.model.playback.SubtitleHlsRendition
import org.prairieserver.prairie.model.playback.SUBTITLE_DELIVERY_BURN_IN_ONLY
import org.prairieserver.prairie.model.playback.SUBTITLE_DELIVERY_SIDECAR
import org.prairieserver.prairie.model.playback.resolvedSelectedSubtitleIndex
@@ -78,6 +79,10 @@ internal fun PlaybackPlanV3.toSessionResponse(
),
)
}
+ val selectedHlsRendition = subtitle.hlsRendition?.takeIf {
+ delivery == PlaybackDelivery.SERVER_TRANSCODE_HLS &&
+ (subtitle.mode == PlaybackSubtitleModeV3.CONVERT || subtitle.mode == PlaybackSubtitleModeV3.RENDER)
+ }?.let { SubtitleHlsRendition(groupId = it.groupId, name = it.name) }
// Neutral v3 publishes the complete subtitle inventory on every plan,
// including plans with subtitles off. Project it into native phone/TV UI
// state so both menus retain every authoritative ordinal. The player mount
@@ -92,6 +97,12 @@ internal fun PlaybackPlanV3.toSessionResponse(
it.delivery == SUBTITLE_DELIVERY_BURN_IN_ONLY
}
.map { item ->
+ // The stream itself carries a rendition-selected track. Its
+ // inventory URL is a whole-file extraction, the slow path the
+ // rendition replaces, so this row publishes no URL to mount.
+ val rendition = selectedHlsRendition.takeIf {
+ item.trackId == subtitle.trackId && item.combinedIndex == selectedSubtitleIndex
+ }
PlayerSubtitleInfo(
index = item.combinedIndex,
language = item.language,
@@ -100,6 +111,7 @@ internal fun PlaybackPlanV3.toSessionResponse(
source = item.source,
forced = item.forced,
url = if (
+ rendition == null &&
subtitle.mode != PlaybackSubtitleModeV3.BURN_IN &&
item.delivery == SUBTITLE_DELIVERY_SIDECAR
) item.url.orEmpty() else "",
@@ -109,6 +121,7 @@ internal fun PlaybackPlanV3.toSessionResponse(
nativeContainerTrackId = subtitle.embedded?.containerTrackId.takeIf {
item.trackId == subtitle.trackId && item.combinedIndex == selectedSubtitleIndex
},
+ hlsRendition = rendition,
serverTrackId = item.trackId,
serverDelivery = item.delivery.takeIf {
it == SUBTITLE_DELIVERY_SIDECAR ||
@@ -181,6 +194,7 @@ internal fun PlaybackPlanV3.toSessionResponse(
requestedMediaFileId = requestedMediaFileId ?: mediaFileId,
effectiveMediaFileId = effectiveFileId,
effectiveRecipe = effectiveRecipe,
+ sourceFile = source,
)
return PlaybackSessionResponse(
sessionId = sessionId,
diff --git a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/PlayerOkHttpClient.kt b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/PlayerOkHttpClient.kt
index bd90d90de9..6df98eeb05 100644
--- a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/PlayerOkHttpClient.kt
+++ b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/PlayerOkHttpClient.kt
@@ -11,6 +11,8 @@ import kotlinx.coroutines.runBlocking
import org.prairieserver.prairie.network.CleartextOriginConsent
import org.prairieserver.prairie.network.CleartextOriginNotApprovedException
import org.prairieserver.prairie.network.isSameHttpOrigin
+import org.prairieserver.prairie.network.localroute.asRoutedFor
+import org.prairieserver.prairie.network.localroute.localRoute
import org.prairieserver.prairie.network.requiresApproval
import java.util.concurrent.TimeUnit
@@ -22,6 +24,10 @@ import java.util.concurrent.TimeUnit
* the app-level Ktor client โ this one only serves media; the auth/refresh
* chain on the Ktor client must not see media traffic. Media3 auth lives above
* this transport in [RefreshingHttpDataSource].
+ *
+ * Video requests marked by [PrairiePlayerFactory] take the active server's local
+ * route ([org.prairieserver.prairie.network.localroute.LocalRouteInterceptor]);
+ * everything else here stays on the address it names.
*/
internal fun buildPlayerOkHttpClient(
cleartextOriginConsent: CleartextOriginConsent? = null,
@@ -38,9 +44,14 @@ internal fun buildPlayerOkHttpClient(
// cannot ride an HTTPSโHTTP or approvedโunapproved redirect.
.addNetworkInterceptor(CleartextConsentNetworkInterceptor(cleartextOriginConsent))
.addNetworkInterceptor { chain ->
- val initialTarget = chain.call().request().url.toString()
val networkRequest = chain.request()
- val safeRequest = if (isSameHttpOrigin(initialTarget, networkRequest.url.toString())) {
+ val callUrl = chain.call().request().url
+ val networkUrl = networkRequest.url.toString()
+ // A request on the local route stands in for the saved address, so
+ // either origin is the call's own.
+ val sameOrigin = isSameHttpOrigin(callUrl.toString(), networkUrl) ||
+ isSameHttpOrigin(callUrl.asRoutedFor(networkRequest).toString(), networkUrl)
+ val safeRequest = if (sameOrigin) {
networkRequest
} else {
networkRequest.withoutCrossOriginCredentials()
@@ -50,6 +61,7 @@ internal fun buildPlayerOkHttpClient(
.protocols(listOf(Protocol.HTTP_2, Protocol.HTTP_1_1))
.connectionPool(ConnectionPool(maxIdleConnections = 5, keepAliveDuration = 5, timeUnit = TimeUnit.MINUTES))
.dispatcher(Dispatcher())
+ .localRoute(media = true)
.build()
internal class CleartextConsentNetworkInterceptor(
diff --git a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/PrairiePlayerFactory.kt b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/PrairiePlayerFactory.kt
index 011a92e192..4821c14c5d 100644
--- a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/PrairiePlayerFactory.kt
+++ b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/PrairiePlayerFactory.kt
@@ -39,6 +39,9 @@ import androidx.media3.extractor.text.SubtitleExtractor
import androidx.media3.extractor.text.SubtitleParser
import androidx.media3.extractor.ts.DefaultTsPayloadReaderFactory
import androidx.media3.extractor.ts.TsExtractor
+import org.prairieserver.prairie.network.httpOrigin
+import org.prairieserver.prairie.network.localroute.LocalRouteHeaders
+import org.prairieserver.prairie.network.localroute.LocalRoutes
import org.prairieserver.prairie.common.player.audio.DelayAudioProcessor
import org.prairieserver.prairie.common.player.audio.HdmiSinkDtsSupport
import org.prairieserver.prairie.common.player.audio.PassthroughSuppressingAudioSink
@@ -94,6 +97,22 @@ class PrairiePlayerFactory(
)
@Volatile private var requestHeaderScope: RequestHeaderScope? = null
+
+ /**
+ * Whether the stream on screen travels over the active server's local
+ * route, decided when the stream starts and kept for its whole life: a
+ * remount of the same stream (a subtitle change, a refresh) keeps the
+ * decision, so a stream that started on the saved address never moves onto
+ * a route, and one whose route has gone stays on the saved address.
+ * [generation] is the route it started on, or null for the saved address.
+ */
+ private data class MediaRouteScope(
+ val streamUri: android.net.Uri,
+ val generation: Long?,
+ val isPlaylist: Boolean,
+ )
+
+ @Volatile private var mediaRouteScope: MediaRouteScope? = null
@Volatile private var resumableDirectPlayUri: android.net.Uri? = null
private val runtimeCorrectionState = PlaybackRuntimeCorrectionState()
@@ -105,6 +124,7 @@ class PrairiePlayerFactory(
*/
val baseLayerDecoderMismatch = kotlinx.coroutines.flow.MutableStateFlow(null)
+ /** Sidecar subtitles: always the address their URL names. */
private val dataSourceFactory = AuthenticatedDataSourceFactory(
context = context,
httpDataSourceFactory = httpDataSourceFactory,
@@ -114,6 +134,16 @@ class PrairiePlayerFactory(
isResumableDirectPlayUri = ::isResumableDirectPlayUri,
)
+ /** The video itself (manifest, segments, progressive bytes): may take the local route. */
+ private val contentDataSourceFactory = AuthenticatedDataSourceFactory(
+ context = context,
+ httpDataSourceFactory = httpDataSourceFactory,
+ authSession = mediaAuthSession,
+ serverUrlProvider = { serverUrl },
+ requestHeadersProvider = { uri -> requestHeadersFor(uri).withLocalRoute(localRouteHeadersFor(uri)) },
+ isResumableDirectPlayUri = ::isResumableDirectPlayUri,
+ )
+
private val embeddedSubtitleParserFactory = OffsetSubtitleParserFactory(
offsetUsProvider = subtitleOffsetHolder::getUserOffsetUs,
delegate = libassBridge.parserFactory,
@@ -332,10 +362,10 @@ class PrairiePlayerFactory(
dolbyVisionBaseLayerRoute = dolbyVisionBaseLayerRoute,
),
)
- .setDataSourceFactory(dataSourceFactory)
+ .setDataSourceFactory(contentDataSourceFactory)
.setSubtitleParserFactory(embeddedSubtitleParserFactory)
.setLoadErrorHandlingPolicy(mediaLoadErrorHandlingPolicy)
- val hlsMediaSourceFactory = HlsMediaSource.Factory(dataSourceFactory)
+ val hlsMediaSourceFactory = HlsMediaSource.Factory(contentDataSourceFactory)
.setExtractorFactory(hlsExtractorFactory)
.setSubtitleParserFactory(embeddedSubtitleParserFactory)
.setLoadErrorHandlingPolicy(mediaLoadErrorHandlingPolicy)
@@ -499,6 +529,7 @@ class PrairiePlayerFactory(
transformations: List = emptyList(),
runtimeCorrections: List = emptyList(),
activeClaims: List = emptyList(),
+ allowsLocalRoute: Boolean = false,
): MediaItem {
this.serverUrl = serverUrl
// Runtime corrections and plan-scoped claims share one activation
@@ -515,6 +546,13 @@ class PrairiePlayerFactory(
requestHeaderScope = requestHeaders.takeIf { it.isNotEmpty() }?.let {
RequestHeaderScope(android.net.Uri.parse(absoluteUrl), it)
}
+ val mimeType = mediaItemMimeType(playMethod, container, delivery)
+ mediaRouteScope = mediaRouteScopeFor(
+ streamUri = android.net.Uri.parse(absoluteUrl),
+ serverUrl = serverUrl,
+ allowsLocalRoute = allowsLocalRoute,
+ isPlaylist = mimeType == MimeTypes.APPLICATION_M3U8,
+ )
val subtitleConfigurations =
subtitleManager.buildSubtitleConfigurations(subtitles, serverUrl)
@@ -557,7 +595,7 @@ class PrairiePlayerFactory(
builder.setMediaMetadata(metadataBuilder.build())
}
- mediaItemMimeType(playMethod, container, delivery)?.let { builder.setMimeType(it) }
+ mimeType?.let { builder.setMimeType(it) }
return builder.build()
}
@@ -572,20 +610,43 @@ class PrairiePlayerFactory(
val scope = requestHeaderScope ?: return emptyMap()
val captured = scope.headers as? org.prairieserver.prairie.network.apiv2.ProxyAuxiliaryRequestHeaders
if (captured != null) return scopedProxyRequestHeaders(uri.toString(), captured)
- val issued = scope.streamUri
- if (!uri.scheme.equals(issued.scheme, ignoreCase = true) ||
- !uri.host.equals(issued.host, ignoreCase = true) ||
- uri.port != issued.port
- ) return emptyMap()
- val issuedPath = issued.path.orEmpty()
- val issuedDirectory = issuedPath.substringBeforeLast('/', missingDelimiterValue = issuedPath)
- .trimEnd('/') + "/"
- val candidatePath = uri.path.orEmpty()
- return if (candidatePath == issuedPath || candidatePath.startsWith(issuedDirectory)) {
- scope.headers
- } else {
- emptyMap()
+ return if (uri.isWithinIssued(scope.streamUri, includeDirectory = true)) scope.headers else emptyMap()
+ }
+
+ /**
+ * The route decision for [streamUri]: the one it already has when this is a
+ * remount of the stream on screen, else the active local route when the
+ * stream may take it. Only video on the saved address under `/api/` moves:
+ * its requests carry fresh session credentials each time, which a node or
+ * signed URL does not.
+ */
+ private fun mediaRouteScopeFor(
+ streamUri: android.net.Uri,
+ serverUrl: String,
+ allowsLocalRoute: Boolean,
+ isPlaylist: Boolean,
+ ): MediaRouteScope {
+ mediaRouteScope
+ ?.takeIf { it.streamUri == streamUri && (allowsLocalRoute || it.generation == null) }
+ ?.let { return it }
+ val route = LocalRoutes.state.active.value?.takeIf { route ->
+ allowsLocalRoute &&
+ streamUri.path.orEmpty().startsWith("/api/") &&
+ httpOrigin(serverUrl) == route.savedOrigin &&
+ httpOrigin(streamUri.toString()) == route.savedOrigin
}
+ return MediaRouteScope(streamUri, route?.generation, isPlaylist)
+ }
+
+ /**
+ * Marks the stream's own requests for the route it started on: the stream
+ * URL itself, and for HLS the playlists and segments beside it.
+ */
+ private fun localRouteHeadersFor(uri: android.net.Uri): Map {
+ val scope = mediaRouteScope ?: return emptyMap()
+ val generation = scope.generation ?: return emptyMap()
+ if (!uri.isWithinIssued(scope.streamUri, includeDirectory = scope.isPlaylist)) return emptyMap()
+ return mapOf(LocalRouteHeaders.MEDIA to generation.toString())
}
private fun isResumableDirectPlayUri(uri: android.net.Uri): Boolean =
@@ -856,3 +917,26 @@ internal fun mediaItemMimeType(
*/
internal fun shouldSkipTrackReselection(playbackState: Int, timelineEmpty: Boolean): Boolean =
playbackState == Player.STATE_IDLE || timelineEmpty
+
+/**
+ * [this] plus the local-route marker. Only `/api/` media is ever marked, never a
+ * proxy URL, so captured proxy headers come back as they are.
+ */
+private fun Map.withLocalRoute(marker: Map): Map =
+ if (marker.isEmpty()) this else this + marker
+
+/**
+ * Whether [this] is the [issued] stream URL or, with [includeDirectory], a
+ * playlist or segment beside it, on the same origin.
+ */
+private fun android.net.Uri.isWithinIssued(issued: android.net.Uri, includeDirectory: Boolean): Boolean {
+ if (!scheme.equals(issued.scheme, ignoreCase = true) ||
+ !host.equals(issued.host, ignoreCase = true) ||
+ port != issued.port
+ ) return false
+ val issuedPath = issued.path.orEmpty()
+ val candidatePath = path.orEmpty()
+ if (candidatePath == issuedPath) return true
+ val issuedDirectory = issuedPath.substringBeforeLast('/', missingDelimiterValue = issuedPath).trimEnd('/') + "/"
+ return includeDirectory && candidatePath.startsWith(issuedDirectory)
+}
diff --git a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/SubtitleMountResolver.kt b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/SubtitleMountResolver.kt
index e9d8d696c8..b5cc349e8b 100644
--- a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/SubtitleMountResolver.kt
+++ b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/SubtitleMountResolver.kt
@@ -1,6 +1,7 @@
package org.prairieserver.prairie.common.player
import org.prairieserver.prairie.model.playback.PlayerSubtitleInfo
+import org.prairieserver.prairie.model.playback.SubtitleHlsRendition
import org.prairieserver.prairie.model.playback.SubtitleIdentity
import org.prairieserver.prairie.model.playback.SubtitleMediaIdentity
import org.prairieserver.prairie.model.playback.isLocalDownloadedSubtitle
@@ -80,6 +81,9 @@ fun resolveMountedSubtitle(
identity: SubtitleIdentity,
tracks: List,
): MountedSubtitleMatch? {
+ if (identity is SubtitleIdentity.Embedded && identity.hlsRendition != null) {
+ return resolveHlsRenditionTrack(requireNotNull(identity.hlsRendition), tracks)
+ }
if (identity is SubtitleIdentity.Embedded && identity.containerTrackId != null) {
return tracks.filter { trackIdDenotes(it.trackId, requireNotNull(identity.containerTrackId)) }
.singleOrNull()?.let(::MountedSubtitleMatch)
@@ -135,7 +139,7 @@ fun resolveMountedSubtitle(
subtitle: PlayerSubtitleInfo,
tracks: List,
): MountedSubtitleMatch? {
- if (subtitle.nativeContainerTrackId != null || subtitle.serverDelivery != null) {
+ if (subtitle.hlsRendition != null || subtitle.nativeContainerTrackId != null || subtitle.serverDelivery != null) {
return resolveMountedSubtitle(org.prairieserver.prairie.playback.playbackSubtitleIdentity(subtitle), tracks)
}
val explicitSource = subtitle.effectiveSubtitleSource()
@@ -197,6 +201,27 @@ fun resolveMountedSubtitle(
return null
}
+/**
+ * Finds the text track Media3 built from the plan's `EXT-X-MEDIA` subtitle
+ * rendition. `HlsPlaylistParser` names it `GROUP-ID:NAME` and labels it `NAME`,
+ * and the HLS period keeps both, so the id is the exact match. Should that id
+ * scheme change, the label still identifies the rendition when exactly one
+ * non-sidecar track carries it. Language never does: the rendition declares
+ * none, and an in-band CEA-608 track (unlabelled) must not be taken for it.
+ * Two candidates are ambiguous and resolve to nothing, which fails the mount.
+ */
+private fun resolveHlsRenditionTrack(
+ rendition: SubtitleHlsRendition,
+ tracks: List,
+): MountedSubtitleMatch? {
+ val exact = tracks.filter { trackIdDenotes(it.trackId, rendition.media3TrackId) }
+ if (exact.isNotEmpty()) return exact.singleOrNull()?.let(::MountedSubtitleMatch)
+ return tracks
+ .filter { !it.hasReservedArtifactId() && it.label.normalizedValue() == rendition.name.trim() }
+ .singleOrNull()
+ ?.let(::MountedSubtitleMatch)
+}
+
private fun SubtitleIdentity.expectedMediaTrackId(): String? = when (this) {
is SubtitleIdentity.ServerSidecar -> subtitleArtifactTrackId(serverIndex)
is SubtitleIdentity.Embedded -> media.trackId.normalizedNonServerTrackId()
diff --git a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/VideoPlayerMediaMounter.kt b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/VideoPlayerMediaMounter.kt
index 04bd887adf..a4af8f8162 100644
--- a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/VideoPlayerMediaMounter.kt
+++ b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/VideoPlayerMediaMounter.kt
@@ -32,6 +32,7 @@ fun mountVideoMedia(
transformations = spec.transformations,
runtimeCorrections = spec.runtimeCorrections,
activeClaims = spec.activeClaims,
+ allowsLocalRoute = spec.allowsLocalRoute,
)
player.setMediaItem(mediaItem, startPositionMs.coerceAtLeast(0L))
player.prepare()
@@ -66,6 +67,7 @@ fun refreshMountedVideoMedia(
transformations = spec.transformations,
runtimeCorrections = spec.runtimeCorrections,
activeClaims = spec.activeClaims,
+ allowsLocalRoute = spec.allowsLocalRoute,
)
player.setMediaItem(mediaItem, resumePositionMs)
player.prepare()
diff --git a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/VideoPlayerMediaSpec.kt b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/VideoPlayerMediaSpec.kt
index 5a797d5fe4..90cc7c4654 100644
--- a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/VideoPlayerMediaSpec.kt
+++ b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/VideoPlayerMediaSpec.kt
@@ -16,7 +16,10 @@ import org.prairieserver.prairie.playback.isBitmapSubtitleCodecFamily
import org.prairieserver.prairie.playback.isClientMountableBitmapCodecFamily
import org.prairieserver.prairie.playback.subtitleLabelIndicatesHearingImpaired
-/** Mounts only the selected artifact. Explicit embedded decisions mount none. */
+/**
+ * Mounts only the selected artifact. Explicit embedded decisions, including a
+ * track the HLS stream carries as a rendition, mount none.
+ */
fun subtitlesForVideoMediaMount(
subtitles: List,
playbackPlan: PlaybackExecutionPlan?,
@@ -35,7 +38,8 @@ fun subtitlesForVideoMediaMount(
}
}
?.takeUnless { row ->
- preferMuxedTracks && row.isMuxedInDirectPlayStream(playbackPlan)
+ row.hlsRendition != null ||
+ preferMuxedTracks && row.isMuxedInDirectPlayStream(playbackPlan)
}
}
is SubtitleIdentity.Downloaded -> subtitles.singleOrNull { subtitle ->
@@ -152,6 +156,12 @@ data class VideoPlayerMediaSpec(
* derived from the plan's decision reason. Drives renderer decoder routing.
*/
val activeClaims: List = emptyList(),
+ /**
+ * Whether this stream may take the active server's local route. False for
+ * Watch Party playback, which stays on the saved address it can always
+ * remount from.
+ */
+ val allowsLocalRoute: Boolean = false,
) {
val startPositionMs: Long
get() {
diff --git a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/video/VideoPlayerRouteArgs.kt b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/video/VideoPlayerRouteArgs.kt
index 8c6a54dd90..0ebade9b9c 100644
--- a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/video/VideoPlayerRouteArgs.kt
+++ b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/video/VideoPlayerRouteArgs.kt
@@ -1,5 +1,7 @@
package org.prairieserver.prairie.common.player.video
+import org.prairieserver.prairie.model.playback.videoQualityPreference
+
object VideoPlayerRouteArgs {
const val RESUME_POSITION = "resumePosition"
const val QUALITY = "quality"
@@ -14,10 +16,9 @@ object VideoPlayerRouteArgs {
return valid.toString()
}
- fun normalizeQuality(value: String?): String? = value
- ?.trim()
- ?.lowercase()
+ /** A route's quality, with a legacy "original" link read as Auto. */
+ fun normalizeQuality(value: String?): String? = videoQualityPreference(value)
?.takeIf { it in PLAYBACK_QUALITIES }
- private val PLAYBACK_QUALITIES = setOf("auto", "original", "2160p", "1080p", "720p", "480p")
+ private val PLAYBACK_QUALITIES = setOf("auto", "2160p", "1080p", "720p", "480p")
}
diff --git a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/watchparty/WatchPartyPlayback.kt b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/watchparty/WatchPartyPlayback.kt
index f0dfc90653..a13dd8f1e9 100644
--- a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/watchparty/WatchPartyPlayback.kt
+++ b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/watchparty/WatchPartyPlayback.kt
@@ -57,6 +57,12 @@ class WatchPartyPlayback(
val catchingUp: StateFlow get() = binding.catchingUp
val offerLowerQuality: StateFlow get() = binding.offerLowerQuality
+ /** The unanswered target of this member's last seek request; the timeline shows it while set. */
+ val pendingSeekSeconds: StateFlow get() = binding.pendingSeekSeconds
+
+ /** Where a relative skip starts: the pending seek target, else [liveSeconds]. */
+ fun relativeSeekOrigin(liveSeconds: Double): Double = binding.relativeSeekOrigin(liveSeconds)
+
/**
* The playback the player must be prepared for. It changes only when the
* room starts a new epoch (a new selection revision or file); anchor and
diff --git a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/requests/RequestRouter.kt b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/requests/RequestRouter.kt
index ccb2ef96d4..73233a8250 100644
--- a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/requests/RequestRouter.kt
+++ b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/requests/RequestRouter.kt
@@ -6,16 +6,13 @@ import androidx.compose.runtime.rememberUpdatedState
import org.prairieserver.prairie.model.request.MediaRequest
import org.prairieserver.prairie.model.request.RequestMediaResult
import org.prairieserver.prairie.model.request.libraryItemToOpen
-import org.prairieserver.prairie.repository.RequestDetailCache
import org.prairieserver.prairie.repository.RequestsRepository
-import org.prairieserver.prairie.repository.cacheKey
/**
* The standard taps on request cards and rows, on phone and TV: a title that
* reads "In library" opens the library item; anything else opens the request
* detail, seeded with what the tapped card already knows so the page is
- * complete on its first frame. Only an approval queue opens a page pinned to
- * one request; every other way in opens an ordinary page.
+ * complete on its first frame.
*/
class RequestRouter internal constructor(
private val repository: RequestsRepository,
@@ -24,20 +21,12 @@ class RequestRouter internal constructor(
) {
fun openResult(result: RequestMediaResult) {
repository.cache.seed(result)
- repository.cache.unpinModeration(RequestDetailCache.Key(result.mediaType, result.tmdbId))
result.libraryItemToOpen()?.let { openLibraryItem()(it) } ?: openRequestDetail()(result.mediaType, result.tmdbId)
}
fun openRecord(record: MediaRequest) {
- repository.cache.unpinModeration(record.cacheKey())
record.libraryItemToOpen()?.let { openLibraryItem()(it) } ?: openRequestDetail()(record.mediaType, record.tmdbId)
}
-
- /** Opens someone else's request from an approval queue: the page decides on that exact request. */
- fun openModerationRecord(record: MediaRequest) {
- repository.cache.pinModeration(record)
- openRequestDetail()(record.mediaType, record.tmdbId)
- }
}
@Composable
diff --git a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/requests/RequestUi.kt b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/requests/RequestUi.kt
index 02476f1cb8..688a3a8cff 100644
--- a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/requests/RequestUi.kt
+++ b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/requests/RequestUi.kt
@@ -122,10 +122,6 @@ object RequestRowCopy {
return progress.longLabel
}
- /** "Series ยท 1080p ยท 4K" for admin rows. */
- fun kindAndQuality(record: MediaRequest): String =
- listOfNotNull(mediaTypeLabel(record.mediaType), RequestTargetSummary.qualities(record.targets)).joinToString(" ยท ")
-
private fun format(timestamp: String?, formatter: DateTimeFormatter): String? {
val value = timestamp?.takeIf { it.isNotBlank() } ?: return null
return runCatching { formatter.format(Instant.parse(value).atZone(ZoneId.systemDefault())) }.getOrNull()
diff --git a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/settings/AndroidPlayerSettingsStore.kt b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/settings/AndroidPlayerSettingsStore.kt
index 69f259f914..d3faacc825 100644
--- a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/settings/AndroidPlayerSettingsStore.kt
+++ b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/settings/AndroidPlayerSettingsStore.kt
@@ -12,6 +12,7 @@ import androidx.datastore.preferences.core.stringSetPreferencesKey
import androidx.datastore.preferences.preferencesDataStoreFile
import org.prairieserver.prairie.domain.player.IntroSkipMode
import org.prairieserver.prairie.model.download.DownloadQuality
+import org.prairieserver.prairie.model.settings.CellularQuality
import org.prairieserver.prairie.model.settings.EffectiveSettingValue
import org.prairieserver.prairie.model.settings.LanguageOptions
import org.prairieserver.prairie.model.settings.PlaybackSettingsKeys
@@ -355,6 +356,14 @@ class AndroidPlayerSettingsStore(
p.intFor(s, PlaybackSettingsKeys.MaxBitrateKbps, 0).takeIf { it > 0 }
}
+ // Unlike the main cap, the cellular one defaults to a value (2000), so an
+ // absent slot reads as that and only an explicit 0 is uncapped.
+ override val cellularMaxBitrateKbpsFlow: Flow =
+ profileScopedFlow(CellularQuality.DEFAULT_MAX_BITRATE_KBPS) { p, s ->
+ p.intFor(s, PlaybackSettingsKeys.CellularMaxBitrateKbps, CellularQuality.DEFAULT_MAX_BITRATE_KBPS)
+ .takeIf { it > 0 }
+ }
+
// ---- Strings -------------------------------------------------------
// Legacy compound spellings ("1080p-high") are normalized on read: the
// bitrate they encoded lives on its own axis now, and handing a compound
@@ -366,6 +375,13 @@ class AndroidPlayerSettingsStore(
)
}
+ override val cellularPreferredQualityFlow: Flow =
+ profileScopedFlow(CellularQuality.DEFAULT_RESOLUTION) { p, s ->
+ QualityPresets.normalizeResolution(
+ p.stringFor(s, PlaybackSettingsKeys.CellularPreferredQuality, CellularQuality.DEFAULT_RESOLUTION),
+ )
+ }
+
// Older builds stored the display name ("English") here rather than a BCP 47
// tag. Those values are rejected by the server and never matched a track, so
// they are translated on read instead of being handed to ExoPlayer or
@@ -529,27 +545,41 @@ class AndroidPlayerSettingsStore(
override suspend fun setPreferredQuality(value: String) =
writeString(PlaybackSettingsKeys.PreferredQuality, QualityPresets.normalizeResolution(value))
- override suspend fun setQuality(resolution: String, bitrateKbps: Int?) {
+ override suspend fun setMaxBitrateKbps(value: Int?) =
+ writeInt(PlaybackSettingsKeys.MaxBitrateKbps, localBitrateKbps(value))
+
+ /**
+ * Both cellular axes in one transaction and two enqueues. Against a server
+ * older than [CellularQuality.MIN_CONTRACT_REVISION] the flusher drops the
+ * writes and the local pair still applies; the pair stays unconfirmed, so
+ * the first refresh from that server once it stores the keys sends it
+ * again instead of replacing it with the server's default.
+ */
+ override suspend fun setCellularQuality(resolution: String, bitrateKbps: Int?) {
val normalized = QualityPresets.normalizeResolution(resolution)
- // 0 is the local spelling of uncapped; the flusher turns it into the
- // JSON null the contract means by "no cap".
- val capped = bitrateKbps?.takeIf { it > 0 }?.coerceIn(MIN_BITRATE_KBPS, MAX_BITRATE_KBPS) ?: 0
+ val capped = localBitrateKbps(bitrateKbps)
withScope { scope, store ->
store.edit {
- it[stringPreferencesKey(scope.keyPrefix + PlaybackSettingsKeys.PreferredQuality)] = normalized
- it[intPreferencesKey(scope.keyPrefix + PlaybackSettingsKeys.MaxBitrateKbps)] = capped
- markDeviceOverride(it, scope, PlaybackSettingsKeys.PreferredQuality, PlaybackSettingsKeys.MaxBitrateKbps)
+ it[stringPreferencesKey(scope.keyPrefix + PlaybackSettingsKeys.CellularPreferredQuality)] = normalized
+ it[intPreferencesKey(scope.keyPrefix + PlaybackSettingsKeys.CellularMaxBitrateKbps)] = capped
+ markDeviceOverride(
+ it,
+ scope,
+ PlaybackSettingsKeys.CellularPreferredQuality,
+ PlaybackSettingsKeys.CellularMaxBitrateKbps,
+ )
+ it[unconfirmedKeysKey(scope)] = it[unconfirmedKeysKey(scope)].orEmpty() + CellularQuality.KEYS
}
serverSettingsFlusher.enqueue(
scope.profileId,
- PlaybackSettingsKeys.PreferredQuality,
+ PlaybackSettingsKeys.CellularPreferredQuality,
normalized,
scope.serverUrl,
scope.authority,
)
serverSettingsFlusher.enqueue(
scope.profileId,
- PlaybackSettingsKeys.MaxBitrateKbps,
+ PlaybackSettingsKeys.CellularMaxBitrateKbps,
capped.toString(),
scope.serverUrl,
scope.authority,
@@ -557,6 +587,14 @@ class AndroidPlayerSettingsStore(
}
}
+ /**
+ * A bitrate cap in its local spelling: within the contract's bounds, or 0
+ * for uncapped, which the flusher turns into the JSON null the contract
+ * means by "no cap".
+ */
+ private fun localBitrateKbps(value: Int?): Int =
+ value?.takeIf { it > 0 }?.coerceIn(MIN_BITRATE_KBPS, MAX_BITRATE_KBPS) ?: 0
+
/**
* `""` (no preference) clears this device's value instead of storing an
* explicit null over the profile's language. The legacy endpoint spelled
@@ -703,7 +741,7 @@ class AndroidPlayerSettingsStore(
if (!isCurrent()) return false
// Batched canonical resolution: one request answers every
// device-relevant key, each with the scope it resolved from.
- val result = repo.getEffectiveValues(RemoteDeviceSettings, authority = authority)
+ val result = repo.getEffectiveValues(remoteKeysStoredBy(scope.serverUrl), authority = authority)
if (result !is ApiResult.Success || !isCurrent()) return false
// Draining is not the same as landing: a write that failed
// transiently stays queued for retry and flushNow still returns
@@ -738,6 +776,56 @@ class AndroidPlayerSettingsStore(
owner.profileToken == current?.profileToken
}
+ /**
+ * [RemoteDeviceSettings] less the keys newer than the server at
+ * [serverUrl] is known to store. A server answers a batched read that
+ * names a key its contract lacks with 404 for the whole batch, so a key
+ * from a later revision ([CellularQuality]) is asked for only once the
+ * revision is known to include it. Until then its local value stands.
+ */
+ private suspend fun remoteKeysStoredBy(serverUrl: String): List {
+ val tracker = contractRevision ?: return RemoteDeviceSettings
+ val revision = runCatching { tracker.revisionFor(serverUrl) }.getOrNull()
+ return RemoteDeviceSettings.filter { key ->
+ val needed = CellularQuality.minimumRevision(key) ?: return@filter true
+ revision != null && revision >= needed
+ }
+ }
+
+ /**
+ * Keys the server at [serverUrl] is not known to store: the revision last
+ * read for it is older than the key, or none has been read. "Use profile
+ * setting" for one of them clears the local value, so the contract
+ * default applies, as the setting would on a server that had no rows for
+ * it. Kept, the value would outlive the reset: an older server drops the
+ * delete, and the refresh never asks for the key. A server that does
+ * store it sends the profile's value once the delete lands.
+ *
+ * Read without probing: a reset that waited on the network could land
+ * after a choice the user made meanwhile and undo it.
+ */
+ private fun keysUnstoredBy(serverUrl: String): Set {
+ val tracker = contractRevision ?: return emptySet()
+ val revision = tracker.known.value?.takeIf { it.serverUrl == serverUrl }?.manifestRevision
+ return RemoteDeviceSettings.filterTo(mutableSetOf()) { key ->
+ val needed = CellularQuality.minimumRevision(key) ?: return@filterTo false
+ revision == null || revision < needed
+ }
+ }
+
+ private fun removeLocalValue(
+ prefs: androidx.datastore.preferences.core.MutablePreferences,
+ scope: Scope,
+ key: String,
+ ) {
+ val scopedName = scope.keyPrefix + key
+ when {
+ isBooleanKey(key) -> prefs.remove(booleanPreferencesKey(scopedName))
+ isIntKey(key) -> prefs.remove(intPreferencesKey(scopedName))
+ else -> prefs.remove(stringPreferencesKey(scopedName))
+ }
+ }
+
override suspend fun setSubtitleDeviceOverrideEnabled(enabled: Boolean) {
withScope { scope, store ->
val snapshot = store.data.first()
@@ -799,9 +887,12 @@ class AndroidPlayerSettingsStore(
val keys = PlaybackSettingsKeys.deviceOverrideGroup(key).filter { it in RemoteDeviceSettings }
if (keys.isEmpty()) return
withScope { scope, store ->
+ val unstored = keysUnstoredBy(scope.serverUrl)
store.edit {
clearDeviceOverride(it, scope, *keys.toTypedArray())
+ it[unconfirmedKeysKey(scope)] = it[unconfirmedKeysKey(scope)].orEmpty() - keys.toSet()
for (cleared in keys) {
+ if (cleared in unstored) removeLocalValue(it, scope, cleared)
serverSettingsFlusher.enqueueDelete(scope.profileId, cleared, scope.serverUrl, scope.authority)
}
}
@@ -818,8 +909,11 @@ class AndroidPlayerSettingsStore(
for (key in RemoteDeviceSettings) {
serverSettingsFlusher.enqueueDelete(scope.profileId, key, scope.serverUrl, scope.authority)
}
+ val unstored = keysUnstoredBy(scope.serverUrl)
store.edit {
it.remove(deviceOverridesKey(scope))
+ it.remove(unconfirmedKeysKey(scope))
+ for (key in unstored) removeLocalValue(it, scope, key)
it[booleanPreferencesKey(scope.keyPrefix + PlaybackSettingsKeys.SubtitleUsesDeviceOverride)] = false
// The local-only playback keys have no server row to delete, so
// the refresh below can never restore their defaults. Removing
@@ -868,6 +962,7 @@ class AndroidPlayerSettingsStore(
if (!isCurrent()) return@edit
applied = true
val overrides = prefs[deviceOverridesKey(scope)].orEmpty().toMutableSet()
+ val unconfirmed = prefs[unconfirmedKeysKey(scope)].orEmpty().toMutableSet()
for (key in RemoteDeviceSettings) {
if (key in unlandedKeys) continue
// The canonical endpoint answers every known key, including
@@ -879,6 +974,18 @@ class AndroidPlayerSettingsStore(
// know (older revision); the local value is kept rather than
// guessed at.
val entry = effective[key] ?: continue
+ if (key in unconfirmed) {
+ if (entry.scope == SettingScope.PROFILE_DEVICE.wire) {
+ unconfirmed -= key
+ } else {
+ // Set while this server could not store it: send it
+ // now that it can, rather than lose it to the default.
+ prefs.localWireValue(scope, key)?.let { value ->
+ serverSettingsFlusher.enqueue(scope.profileId, key, value, scope.serverUrl, scope.authority)
+ }
+ continue
+ }
+ }
writeJsonValue(prefs, scope, key, entry.value)
// Where the value resolved from is the truth about whether
// this device holds its own, including one set elsewhere (the
@@ -886,6 +993,7 @@ class AndroidPlayerSettingsStore(
if (entry.scope == SettingScope.PROFILE_DEVICE.wire) overrides += key else overrides -= key
}
prefs[deviceOverridesKey(scope)] = overrides
+ prefs[unconfirmedKeysKey(scope)] = unconfirmed
// An appearance whose write has not landed leaves the flag and the
// granular overlay alone too: they describe where the composite
// resolved from, and the response predates the queued edit.
@@ -1065,6 +1173,19 @@ class AndroidPlayerSettingsStore(
private fun deviceOverridesKey(scope: Scope): Preferences.Key> =
stringSetPreferencesKey(scope.keyPrefix + DEVICE_OVERRIDE_KEYS)
+ private fun unconfirmedKeysKey(scope: Scope): Preferences.Key> =
+ stringSetPreferencesKey(scope.keyPrefix + UNCONFIRMED_GATED_KEYS)
+
+ /** [key]'s value in this scope, spelled as the flusher takes it, or null when unset. */
+ private fun Preferences.localWireValue(scope: Scope, key: String): String? {
+ val scopedName = scope.keyPrefix + key
+ return when {
+ isBooleanKey(key) -> this[booleanPreferencesKey(scopedName)]?.toString()
+ isIntKey(key) -> this[intPreferencesKey(scopedName)]?.toString()
+ else -> this[stringPreferencesKey(scopedName)]
+ }
+ }
+
/** Records that this device now holds its own value for [keys]. */
private fun markDeviceOverride(
prefs: androidx.datastore.preferences.core.MutablePreferences,
@@ -1155,6 +1276,13 @@ class AndroidPlayerSettingsStore(
* which says where each value resolved from.
*/
const val DEVICE_OVERRIDE_KEYS = "device_override_keys"
+ /**
+ * Local-only: keys from a later contract revision ([CellularQuality])
+ * this device set but no refresh has yet shown stored for it. The
+ * flusher drops them for an older server; this is what sends them
+ * once the server can store them.
+ */
+ const val UNCONFIRMED_GATED_KEYS = "unconfirmed_gated_keys"
const val MIGRATION_SENTINEL_LEGACY = "migration_v1"
const val MISSING_SENTINEL = "__missing__"
// F1/F2 local-only defaults (mirror DefaultResumeRewindSeconds=7.0 and
@@ -1197,7 +1325,10 @@ class AndroidPlayerSettingsStore(
val DOUBLE_KEYS: Set = SettingKeys.DOUBLE_KEYS
/** Int keys whose contract null means "no cap", stored locally as 0. */
- val NULLABLE_INT_SETTINGS: Set = setOf(SettingKeys.PLAYBACK_MAX_BITRATE_KBPS)
+ val NULLABLE_INT_SETTINGS: Set = setOf(
+ SettingKeys.PLAYBACK_MAX_BITRATE_KBPS,
+ SettingKeys.PLAYBACK_CELLULAR_MAX_BITRATE_KBPS,
+ )
fun isBooleanKey(key: String): Boolean = key in BOOLEAN_KEYS
fun isIntKey(key: String): Boolean = key in INT_KEYS
diff --git a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/settings/CardPresentationStore.kt b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/settings/CardPresentationStore.kt
index 5acfbf4a07..1969ffada1 100644
--- a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/settings/CardPresentationStore.kt
+++ b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/settings/CardPresentationStore.kt
@@ -70,6 +70,14 @@ interface CardPresentationStore {
/** Idempotent first-load. Safe to call from every provider mount. */
suspend fun hydrateIfNeeded()
+ /**
+ * Applies this identity's last-known value from the local cache, without
+ * the network, if nothing has loaded yet. Idempotent and cheap after the
+ * first call, so work that runs before the UI (such as sizing the first
+ * Home read) can see the user's choice.
+ */
+ suspend fun seedFromCache() {}
+
/** Re-probe capabilities + re-resolve the effective value. */
suspend fun refresh()
@@ -112,6 +120,10 @@ class DefaultCardPresentationStore(
@Volatile
private var hasHydrated: Boolean = false
+ // Whether this session's cached value has been applied (or there was none).
+ @Volatile
+ private var hasSeeded: Boolean = false
+
// The last state confirmed by a canonical read or write; the rollback
// target for a failed optimistic edit.
private var confirmedState: CardPresentationUiState = _state.value
@@ -146,19 +158,24 @@ class DefaultCardPresentationStore(
// Seed from the last-known cache for this exact identity so cold start
// renders at the right size without a resize jump once the network
// answer lands.
- currentIdentity()?.let { identity ->
- readCachedState(identity)?.let { cached ->
- synchronized(stateLock) {
- // Only paint the cache over untouched (pre-hydration,
- // pre-edit) state.
- if (!hasHydrated && _state.value == confirmedState) {
- confirmedState = cached
- _state.value = cached
- }
+ seedFromCache()
+ refresh()
+ }
+
+ override suspend fun seedFromCache() {
+ if (hasSeeded || hasHydrated) return
+ val identity = currentIdentity() ?: return
+ readCachedState(identity)?.let { cached ->
+ synchronized(stateLock) {
+ // Only paint the cache over untouched (pre-hydration,
+ // pre-edit) state.
+ if (!hasHydrated && _state.value == confirmedState) {
+ confirmedState = cached
+ _state.value = cached
}
}
}
- refresh()
+ hasSeeded = true
}
/**
@@ -441,6 +458,7 @@ class DefaultCardPresentationStore(
_state.value = CardPresentationUiState()
confirmedState = _state.value
hasHydrated = false
+ hasSeeded = false
// Let the next session's hydrateIfNeeded run instead of observing
// the previous session's loading flag and returning early.
_isLoading.value = false
diff --git a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/settings/PlayerSettingsStore.kt b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/settings/PlayerSettingsStore.kt
index 18e2a1c2f5..6c1cd41038 100644
--- a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/settings/PlayerSettingsStore.kt
+++ b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/settings/PlayerSettingsStore.kt
@@ -1,6 +1,7 @@
package org.prairieserver.prairie.common.settings
import org.prairieserver.prairie.domain.player.IntroSkipMode
+import org.prairieserver.prairie.model.settings.CellularQuality
import org.prairieserver.prairie.model.settings.SubtitleAppearance
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first
@@ -110,6 +111,18 @@ interface PlayerSettingsStore {
*/
val maxBitrateKbpsFlow: Flow
+ /**
+ * The phone's cellular pair, sent instead of [preferredQualityFlow] and
+ * [maxBitrateKbpsFlow] on mobile data or a metered network. Defaults to
+ * the 720p preset. Kept and applied locally against a server too old to
+ * store it; see [CellularQuality].
+ */
+ val cellularPreferredQualityFlow: Flow
+ get() = flowOf(CellularQuality.DEFAULT_RESOLUTION)
+ /** null is uncapped, as for [maxBitrateKbpsFlow]. */
+ val cellularMaxBitrateKbpsFlow: Flow
+ get() = flowOf(CellularQuality.DEFAULT_MAX_BITRATE_KBPS)
+
// Strings
val preferredQualityFlow: Flow
val audioLanguageFlow: Flow
@@ -190,14 +203,15 @@ interface PlayerSettingsStore {
suspend fun setPreferredQuality(value: String)
+ /** The bitrate ceiling, independent of the resolution; null is uncapped. */
+ suspend fun setMaxBitrateKbps(value: Int?) = Unit
+
/**
- * Set both quality axes at once โ the two values one picker preset
- * decomposes into. Writing them together is what keeps the pair
- * consistent: a resolution stored without its bitrate is a combination no
- * preset covers, which the picker then has to render as "custom".
+ * Set both cellular axes at once โ the two values one preset decomposes
+ * into, so the pair never holds a combination no preset covers.
* [bitrateKbps] null is uncapped.
*/
- suspend fun setQuality(resolution: String, bitrateKbps: Int?)
+ suspend fun setCellularQuality(resolution: String, bitrateKbps: Int?) = Unit
suspend fun setAudioLanguage(value: String)
suspend fun setVideoGravity(value: String)
suspend fun setOrientationMode(value: String)
diff --git a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/settings/ServerSettingsFlusher.kt b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/settings/ServerSettingsFlusher.kt
index cbadc8f2b1..4e7d7ae363 100644
--- a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/settings/ServerSettingsFlusher.kt
+++ b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/settings/ServerSettingsFlusher.kt
@@ -2,6 +2,7 @@ package org.prairieserver.prairie.common.settings
import org.prairieserver.prairie.common.diagnostics.PrairieLog
import org.prairieserver.prairie.model.diagnostics.DiagnosticsLogCategory
+import org.prairieserver.prairie.model.settings.CellularQuality
import org.prairieserver.prairie.model.settings.SettingKeys
import org.prairieserver.prairie.model.settings.SettingScopeIdentity
import org.prairieserver.prairie.model.settings.SubtitleAppearance
@@ -265,9 +266,16 @@ class DefaultServerSettingsFlusher(
return false
}
return try {
- when (op) {
- is PendingOp.Set -> flushSet(profileId, key, op)
- is PendingOp.Delete -> flushDelete(profileId, key, op)
+ when (serverStores(key, op.serverUrl)) {
+ null -> failed("flush", key, "settings manifest revision unknown", retry = true)
+ false -> {
+ PrairieLog.w(CATEGORY, TAG, "dropping $key: the server's settings manifest predates it; the value stays on this device")
+ false
+ }
+ true -> when (op) {
+ is PendingOp.Set -> flushSet(profileId, key, op)
+ is PendingOp.Delete -> flushDelete(profileId, key, op)
+ }
}
} catch (t: Throwable) {
// Includes cancellation of a superseded flush: the op goes back
@@ -304,6 +312,20 @@ class DefaultServerSettingsFlusher(
}
}
+ /**
+ * Whether the server at [serverUrl] stores [key], or null while that
+ * depends on a manifest revision not known yet. Only keys from a later
+ * revision than every server this app supports ([CellularQuality]) are
+ * checked. A server that lacks one refuses it as unknown_setting, so the
+ * write is dropped and the value stays on the device; while the revision
+ * is unknown it is held and retried like a transient failure.
+ */
+ private suspend fun serverStores(key: String, serverUrl: String): Boolean? {
+ val needed = CellularQuality.minimumRevision(key) ?: return true
+ val revision = contractRevision.revisionFor(serverUrl) ?: return null
+ return revision >= needed
+ }
+
/**
* [encoded] in the shape the server at [serverUrl] accepts, or null when
* that depends on a manifest revision not known yet.
@@ -406,6 +428,7 @@ private val NULLABLE_LANGUAGE_KEYS: Set = setOf(
*/
private val NULLABLE_INT_KEYS: Set = setOf(
SettingKeys.PLAYBACK_MAX_BITRATE_KBPS,
+ SettingKeys.PLAYBACK_CELLULAR_MAX_BITRATE_KBPS,
)
/** Keys whose store-side string is itself a JSON object document. */
diff --git a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/startup/StartupWarmup.kt b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/startup/StartupWarmup.kt
index e80540ee6f..7e1c342667 100644
--- a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/startup/StartupWarmup.kt
+++ b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/startup/StartupWarmup.kt
@@ -22,6 +22,7 @@ import org.prairieserver.prairie.util.ArtworkUrl
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.supervisorScope
+import kotlin.math.roundToInt
/**
* How much home artwork a cold-start warmup pulls through the Coil caches, and
@@ -51,20 +52,23 @@ data class StartupArtworkPlan(
) {
companion object {
/**
- * Hero sizes mirror TvSkylineSectionFeed's preload constants (backdrop
- * 1229ร756, logo 880ร200); posters are RowDimens.PosterWidth/Height
- * (130ร195dp) at the 2ร density of a 1080p TV.
+ * Hero sizes mirror TvSkylineSectionFeed's preload (the 614.4ร378dp
+ * hero art box and the 440ร100dp logo slot); posters are
+ * RowDimens.PosterWidth/Height (130ร195dp). [density] is the display's.
*/
- fun tv() = StartupArtworkPlan(
- maxUrls = 28,
- posterWidthPx = 260,
- posterHeightPx = 390,
- backdropWidthPx = 1229,
- backdropHeightPx = 756,
- logoWidthPx = 880,
- logoHeightPx = 200,
- warmFirstRowHeroArt = true,
- )
+ fun tv(density: Float): StartupArtworkPlan {
+ fun px(dp: Float) = (dp * density).roundToInt()
+ return StartupArtworkPlan(
+ maxUrls = 28,
+ posterWidthPx = px(130f),
+ posterHeightPx = px(195f),
+ backdropWidthPx = px(614.4f),
+ backdropHeightPx = px(378f),
+ logoWidthPx = px(440f),
+ logoHeightPx = px(100f),
+ warmFirstRowHeroArt = true,
+ )
+ }
/** Phone home renders posters and episode stills only โ no hero art. */
fun phone() = StartupArtworkPlan(
@@ -170,6 +174,7 @@ internal suspend fun warmStartupHomeSections(
suspend fun mayWarm(): Boolean = sectionRepository.isHomeAuthorityCurrent(owner) &&
currentCoroutineContext().isActive && stillCurrent()
if (!mayWarm()) return
+ val artworkFingerprint = homeCache.artworkFingerprint()
val result = sectionRepository.getHomeSections(owner)
if (!mayWarm() || result !is ApiResult.Success) return
val hydration = hydrateHomeSections(result.data.sections) { id ->
@@ -177,7 +182,7 @@ internal suspend fun warmStartupHomeSections(
else sectionRepository.getHomeSectionItems(id, owner)
}
if (!mayWarm() || !hydration.fullyResolved || hydration.sections.isEmpty()) return
- homeCache.cacheHomeV2IfAbsent(hydration.sections, owner, stillCurrent)
+ homeCache.cacheHomeV2IfAbsent(hydration.sections, owner, artworkFingerprint, stillCurrent)
if (!mayWarm()) return
warmArtwork(hydration.sections, ::mayWarm)
}
diff --git a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/ui/components/ThumbhashImage.kt b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/ui/components/ThumbhashImage.kt
index d8f200bbdd..3fd25513d6 100644
--- a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/ui/components/ThumbhashImage.kt
+++ b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/ui/components/ThumbhashImage.kt
@@ -2,6 +2,8 @@ package org.prairieserver.prairie.common.ui.components
import android.util.Base64
import android.util.LruCache
+import androidx.compose.animation.core.Animatable
+import androidx.compose.animation.core.tween
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.ScrollableState
@@ -25,6 +27,8 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ColorFilter
+import androidx.compose.ui.graphics.CompositingStrategy
+import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.graphics.painter.BitmapPainter
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
@@ -251,7 +255,8 @@ fun ThumbhashImage(
// whatever the request's placeholder is. With no thumbhash to stand in
// (hero backdrops and title logos frequently have none) that is
// transparent: the artwork appeared, then dissolved up from the page
- // behind it over the crossfade. Reveal it in one frame instead.
+ // behind it over the crossfade. Instead the image fades in over the cover,
+ // which stays drawn until the fade completes.
val gatedModel = remember(model) {
model.newBuilder().crossfade(false).build()
}
@@ -263,6 +268,9 @@ fun ThumbhashImage(
// Once committed, the artwork stays committed through later gestures.
var fullImageReady by remember(url) { mutableStateOf(false) }
var fullImagePresented by remember(url) { mutableStateOf(false) }
+ val requestedAtNanos = remember(url) { System.nanoTime() }
+ val revealAlpha = remember(url) { Animatable(0f) }
+ val revealComplete by remember(revealAlpha) { derivedStateOf { revealAlpha.value >= 1f } }
val currentOnSuccess by rememberUpdatedState(onSuccess)
val presentFullImage = {
if (!fullImagePresented) {
@@ -277,8 +285,21 @@ fun ThumbhashImage(
presentFullImage()
}
+ LaunchedEffect(url, fullImagePresented) {
+ if (!fullImagePresented || revealComplete) return@LaunchedEffect
+ // Artwork that resolves before the cover has been seen (a memory-cache
+ // hit on scroll-back) appears at once, as it always has. Anything the
+ // viewer waited on fades in instead of cutting over.
+ val waitedNanos = System.nanoTime() - requestedAtNanos
+ if (crossfadeMillis <= 0 || waitedNanos < InstantRevealWindowNanos) {
+ revealAlpha.snapTo(1f)
+ } else {
+ revealAlpha.animateTo(1f, tween(crossfadeMillis))
+ }
+ }
+
Box(modifier = modifier) {
- if (!fullImagePresented) {
+ if (!revealComplete) {
when {
placeholder != null -> Image(
painter = placeholder,
@@ -317,9 +338,24 @@ fun ThumbhashImage(
onError = { onLoadError() },
modifier = Modifier
.fillMaxSize()
+ .then(
+ if (revealComplete) {
+ Modifier
+ } else {
+ // ModulateAlpha fades the single bitmap draw without an
+ // offscreen layer.
+ Modifier.graphicsLayer {
+ alpha = revealAlpha.value
+ compositingStrategy = CompositingStrategy.ModulateAlpha
+ }
+ },
+ )
.drawWithContent {
if (fullImagePresented) drawContent()
},
)
}
}
+
+/** A result this soon after the request was never visibly waited on. */
+private const val InstantRevealWindowNanos = 100_000_000L
diff --git a/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/data/repository/RoomCatalogCacheRepositoryTest.kt b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/data/repository/RoomCatalogCacheRepositoryTest.kt
index 550c46b3f4..0c1980ee8b 100644
--- a/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/data/repository/RoomCatalogCacheRepositoryTest.kt
+++ b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/data/repository/RoomCatalogCacheRepositoryTest.kt
@@ -6,9 +6,13 @@ import org.prairieserver.prairie.common.data.db.PrairieDatabase
import org.prairieserver.prairie.model.catalog.BrowseItem
import org.prairieserver.prairie.model.catalog.CatalogResponse
import org.prairieserver.prairie.model.personal.UserLibrary
+import org.prairieserver.prairie.network.ArtworkWidths
import org.prairieserver.prairie.network.AuthScopeSnapshot
import org.prairieserver.prairie.network.DefaultIdentityTransitionBarrier
import org.prairieserver.prairie.network.IdentityTransitionKind
+import org.prairieserver.prairie.repository.ArtworkLayout
+import org.prairieserver.prairie.repository.ArtworkSizing
+import org.prairieserver.prairie.repository.ArtworkSurface
import org.prairieserver.prairie.repository.port.CatalogCacheWriteLease
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.async
@@ -52,6 +56,88 @@ class RoomCatalogCacheRepositoryTest {
assertNull(repo.getCachedLibrarySectionsV2(7, scope!!))
}
+ @Test
+ fun artworkReadsAreKeyedByTheWidthsTheyAreDrawnAt() = runTest {
+ var widths = ArtworkWidths(backdrop = 1344, still = 360)
+ val layout = ArtworkLayout { surface, _ ->
+ if (surface == ArtworkSurface.SEASON_EPISODES) ArtworkWidths(still = 460) else widths
+ }
+ val sized = RoomCatalogCacheRepository(db = db, snapshotProvider = { scope }, now = { 1000L },
+ artwork = ArtworkSizing(layout, null))
+ val detail = org.prairieserver.prairie.model.catalog.ItemDetail(contentId = "c1", type = "series", title = "Show")
+ val episodes = org.prairieserver.prairie.model.catalog.EpisodesResponse()
+ sized.cacheItemDetail("c1", detail)
+ sized.cacheEpisodes("c1", 1, episodes, CatalogCacheWriteLease(0L), ArtworkSurface.EPISODES)
+ assertEquals(detail, sized.getCachedItemDetail("c1"))
+ assertEquals(episodes, sized.getCachedEpisodes("c1", 1, ArtworkSurface.EPISODES))
+ // A season's page draws the stills wider, so it keeps its own copy.
+ assertNull(sized.getCachedEpisodes("c1", 1, ArtworkSurface.SEASON_EPISODES))
+ // Another layout misses rather than serving artwork sized for this one.
+ widths = ArtworkWidths(backdrop = 2560, still = 480)
+ assertNull(sized.getCachedItemDetail("c1"))
+ // The language purge still matches width-keyed rows.
+ widths = ArtworkWidths(backdrop = 1344, still = 360)
+ sized.invalidateDetailMetadata()
+ assertNull(sized.getCachedItemDetail("c1"))
+ assertNull(sized.getCachedEpisodes("c1", 1, ArtworkSurface.EPISODES))
+ }
+
+ @Test
+ fun aReadIsCachedUnderTheLayoutItWasSizedFor() = runTest {
+ val open = ArtworkWidths(backdrop = 2152)
+ val folded = ArtworkWidths(backdrop = 2423)
+ var widths = open
+ val artwork = ArtworkSizing({ _, _ -> widths }, null)
+ val sized = RoomCatalogCacheRepository(db = db, snapshotProvider = { scope }, now = { 1000L }, artwork = artwork)
+ val detail = org.prairieserver.prairie.model.catalog.ItemDetail(contentId = "c1", type = "movie", title = "Movie")
+ // The read starts open; the device folds before its answer is cached.
+ val captured = artwork.cacheFingerprint(ArtworkSurface.DETAIL)
+ widths = folded
+ sized.cacheItemDetail("c1", detail, CatalogCacheWriteLease(0L, captured))
+ assertNull(sized.getCachedItemDetail("c1"))
+ widths = open
+ assertEquals(detail, sized.getCachedItemDetail("c1"))
+ }
+
+ @Test
+ fun rowsCachedBeforeArtworkWidthsAreReadUntilReplaced() = runTest {
+ // An earlier app version cached under the bare key.
+ val detail = org.prairieserver.prairie.model.catalog.ItemDetail(contentId = "c1", type = "movie", title = "Before")
+ val episodes = org.prairieserver.prairie.model.catalog.EpisodesResponse()
+ val page = CatalogResponse(total = 1, items = listOf(BrowseItem(contentId = "c1", type = "movie", title = "A")))
+ repo.cacheItemDetail("c1", detail)
+ repo.cacheEpisodes("s1", 1, episodes)
+ repo.cacheDefaultLibraryPage(7, page)
+ val sized = RoomCatalogCacheRepository(db = db, snapshotProvider = { scope }, now = { 1000L },
+ artwork = ArtworkSizing({ _, _ -> ArtworkWidths(poster = 300, backdrop = 1344, still = 360) }, null))
+ assertEquals(detail, sized.getCachedItemDetail("c1"))
+ assertEquals(episodes, sized.getCachedEpisodes("s1", 1, ArtworkSurface.EPISODES))
+ assertEquals(page, sized.getCachedDefaultLibraryPage(7))
+ // Caching the item again retires the old row.
+ val after = detail.copy(title = "After")
+ sized.cacheItemDetail("c1", after)
+ assertEquals(after, sized.getCachedItemDetail("c1"))
+ assertNull(repo.getCachedItemDetail("c1"))
+ }
+
+ @Test
+ fun retiringALegacyRowLeavesAnotherProfilesRow() = runTest {
+ val first = scope!!
+ val second = AuthScopeSnapshot("s1", "p2", "https://s1.example", null)
+ val detail = org.prairieserver.prairie.model.catalog.ItemDetail(contentId = "c1", type = "movie", title = "Before")
+ repo.cacheItemDetail("c1", detail)
+ scope = second
+ repo.cacheItemDetail("c1", detail)
+ // The write goes to the first profile; the profile switches while Room stores it.
+ var reads = 0
+ val sized = RoomCatalogCacheRepository(db = db, snapshotProvider = { if (reads++ == 0) first else second },
+ now = { 1000L }, artwork = ArtworkSizing({ _, _ -> ArtworkWidths(backdrop = 1344) }, null))
+ sized.cacheItemDetail("c1", detail.copy(title = "After"))
+ assertEquals(detail, repo.getCachedItemDetail("c1"))
+ scope = first
+ assertNull(repo.getCachedItemDetail("c1"))
+ }
+
@Test
fun roundTripsLibraries() = runTest {
assertNull(repo.getCachedLibraries())
diff --git a/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/data/repository/RoomHomeCacheRepositoryTest.kt b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/data/repository/RoomHomeCacheRepositoryTest.kt
index bbf8f87afb..638a881f00 100644
--- a/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/data/repository/RoomHomeCacheRepositoryTest.kt
+++ b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/data/repository/RoomHomeCacheRepositoryTest.kt
@@ -6,9 +6,11 @@ import androidx.test.core.app.ApplicationProvider
import org.prairieserver.prairie.common.data.db.PrairieDatabase
import org.prairieserver.prairie.model.section.ResolvedSection
import org.prairieserver.prairie.model.section.SectionItem
+import org.prairieserver.prairie.network.ArtworkWidths
import org.prairieserver.prairie.network.AuthScopeSnapshot
import org.prairieserver.prairie.network.DefaultIdentityTransitionBarrier
import org.prairieserver.prairie.network.IdentityTransitionKind
+import org.prairieserver.prairie.repository.ArtworkSizing
import org.prairieserver.prairie.repository.port.HomeCacheWriteLease
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.async
@@ -53,6 +55,43 @@ class RoomHomeCacheRepositoryTest {
assertNull(repo.getCachedHomeV2(scope!!))
}
+ @Test
+ fun homeIsKeyedByTheWidthsItIsDrawnAt() = runTest {
+ val owner = scope!!
+ var widths = ArtworkWidths(poster = 176, backdrop = 1344, logo = 880)
+ val sized = RoomHomeCacheRepository(db = db, snapshotProvider = { scope }, now = { 1000L },
+ artwork = ArtworkSizing({ _, _ -> widths }, null))
+ val rows = listOf(section("row", "item"))
+ sized.cacheHomeV2(rows, owner)
+ assertEquals(rows, sized.getCachedHomeV2(owner)?.sections)
+ assertNull(repo.getCachedHomeV2(owner))
+ widths = ArtworkWidths(poster = 117, backdrop = 895, logo = 586)
+ assertNull(sized.getCachedHomeV2(owner))
+
+ // A write keeps the fingerprint its read captured, even after the layout changed.
+ val captured = sized.artworkFingerprint()
+ widths = ArtworkWidths(poster = 176, backdrop = 1344, logo = 880)
+ val refreshed = listOf(section("row", "other"))
+ sized.cacheHomeV2(refreshed, owner, captured)
+ assertEquals(rows, sized.getCachedHomeV2(owner)?.sections)
+ widths = ArtworkWidths(poster = 117, backdrop = 895, logo = 586)
+ assertEquals(refreshed, sized.getCachedHomeV2(owner)?.sections)
+ }
+
+ @Test
+ fun homeCachedBeforeArtworkWidthsIsReadUntilReplaced() = runTest {
+ val owner = scope!!
+ val before = listOf(section("row", "before"))
+ repo.cacheHomeV2(before, owner)
+ val sized = RoomHomeCacheRepository(db = db, snapshotProvider = { scope }, now = { 1000L },
+ artwork = ArtworkSizing({ _, _ -> ArtworkWidths(poster = 176) }, null))
+ assertEquals(before, sized.getCachedHomeV2(owner)?.sections)
+ val after = listOf(section("row", "after"))
+ sized.cacheHomeV2(after, owner)
+ assertEquals(after, sized.getCachedHomeV2(owner)?.sections)
+ assertNull(repo.getCachedHomeV2(owner))
+ }
+
@Test
fun supersededHomeWriterChecksRunAfterWaitingForTransaction() = runTest {
val owner = scope!!
diff --git a/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/discovery/LanServerDiscoveryTest.kt b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/discovery/LanServerDiscoveryTest.kt
new file mode 100644
index 0000000000..fbe1ec2d75
--- /dev/null
+++ b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/discovery/LanServerDiscoveryTest.kt
@@ -0,0 +1,324 @@
+package org.prairieserver.prairie.common.discovery
+
+import kotlinx.coroutines.CompletableDeferred
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.test.StandardTestDispatcher
+import kotlinx.coroutines.test.TestScope
+import kotlinx.coroutines.test.advanceTimeBy
+import kotlinx.coroutines.test.runCurrent
+import kotlinx.coroutines.test.runTest
+import org.prairieserver.prairie.network.api.ServerIdentityProbe
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertNull
+import kotlin.test.assertTrue
+
+@OptIn(ExperimentalCoroutinesApi::class)
+class LanServerDiscoveryTest {
+ private val dispatcher = StandardTestDispatcher()
+
+ private class FakeBrowser : LanServiceBrowser {
+ var events: LanServiceBrowser.Events? = null
+ var starts = 0
+
+ override fun start(events: LanServiceBrowser.Events) {
+ starts++
+ this.events = events
+ }
+
+ override fun stop() {
+ events = null
+ }
+
+ override fun refresh() = Unit
+ }
+
+ /** Identity answers by URL; anything missing is unreachable. */
+ private val identities = mutableMapOf()
+ private val names = mutableMapOf()
+ private val probed = mutableListOf()
+ private val browser = FakeBrowser()
+
+ private fun discovery() = LanServerDiscovery(
+ browser = browser,
+ probeIdentity = { url ->
+ probed += url
+ identities[url]?.let { ServerIdentityProbe.Identity(it) } ?: ServerIdentityProbe.Unreachable
+ },
+ fetchName = { names[it] },
+ dispatcher = dispatcher,
+ )
+
+ private fun ad(id: String?, vararg addresses: String, port: Int = 8090, name: String = "Prairie") = LanAdvertisement(
+ instanceName = name,
+ addresses = addresses.toList(),
+ port = port,
+ attributes = buildMap {
+ put("v", "1".encodeToByteArray())
+ put("future", "x".encodeToByteArray())
+ if (id != null) put("id", id.encodeToByteArray())
+ },
+ )
+
+ private fun TestScope.resolve(key: String, advertisement: LanAdvertisement) {
+ browser.events!!.resolved(key, advertisement)
+ runCurrent()
+ }
+
+ @Test
+ fun listsAnAdvertisementOnlyWhenItsAddressConfirmsTheAdvertisedId() = runTest(dispatcher) {
+ identities["http://192.168.1.5:8090"] = "srv-a"
+ identities["http://192.168.1.6:8090"] = "srv-b"
+ names["http://192.168.1.5:8090"] = "Den"
+ val discovery = discovery()
+ discovery.start()
+
+ resolve("a", ad("srv-a", "192.168.1.5"))
+ // Advertises one deployment's id, but its address answers as another.
+ resolve("spoof", ad("srv-a", "192.168.1.6", name = "Den"))
+ advanceTimeBy(LanServerDiscovery.RETRY_DELAY_MS + 1)
+
+ assertEquals(
+ listOf(DiscoveredServer(serverId = "srv-a", name = "Den", url = "http://192.168.1.5:8090")),
+ discovery.servers.value,
+ )
+ assertEquals("On this network ยท 192.168.1.5:8090", discovery.servers.value.single().detail)
+ discovery.stop()
+ }
+
+ @Test
+ fun advertisementWithoutAnIdIsNotProbed() = runTest(dispatcher) {
+ val discovery = discovery()
+ discovery.start()
+
+ resolve("a", ad(null, "192.168.1.5"))
+
+ assertEquals(emptyList(), probed)
+ assertEquals(emptyList(), discovery.servers.value)
+ discovery.stop()
+ }
+
+ @Test
+ fun resultsWithOneIdShareARowSortedByName() = runTest(dispatcher) {
+ identities["http://192.168.1.5:8090"] = "srv-a"
+ identities["http://10.0.0.5:8090"] = "srv-a"
+ identities["http://192.168.1.9:8090"] = "srv-b"
+ names["http://192.168.1.5:8090"] = "Zoo"
+ names["http://10.0.0.5:8090"] = "Zoo"
+ names["http://192.168.1.9:8090"] = "attic"
+ val discovery = discovery()
+ discovery.start()
+
+ resolve("a-wifi", ad("srv-a", "192.168.1.5"))
+ resolve("a-eth", ad("srv-a", "10.0.0.5"))
+ resolve("b", ad("srv-b", "192.168.1.9"))
+
+ assertEquals(
+ listOf("attic" to "http://192.168.1.9:8090", "Zoo" to "http://10.0.0.5:8090"),
+ discovery.servers.value.map { it.name to it.url },
+ )
+ discovery.stop()
+ }
+
+ @Test
+ fun anUnreachableFirstAddressFallsBackToTheNext() = runTest(dispatcher) {
+ identities["http://[2001:db8::5]:8090"] = "srv-a"
+ val discovery = discovery()
+ discovery.start()
+
+ resolve("a", ad("srv-a", "192.168.1.5", "fe80::1%wlan0", "2001:db8::5%wlan0", name = "Media"))
+
+ assertEquals(listOf("http://192.168.1.5:8090", "http://[2001:db8::5]:8090"), probed)
+ assertEquals(listOf("Media" to "http://[2001:db8::5]:8090"), discovery.servers.value.map { it.name to it.url })
+ discovery.stop()
+ }
+
+ @Test
+ fun unreachableResultIsRetriedOnceThenAgainOnRefresh() = runTest(dispatcher) {
+ val discovery = discovery()
+ discovery.start()
+
+ resolve("a", ad("srv-a", "192.168.1.5"))
+ advanceTimeBy(LanServerDiscovery.RETRY_DELAY_MS + 1)
+ assertEquals(2, probed.size)
+ assertEquals(emptyList(), discovery.servers.value)
+
+ // The server finishes starting; the next refresh finds it.
+ identities["http://192.168.1.5:8090"] = "srv-a"
+ advanceTimeBy(LanServerDiscovery.REFRESH_INTERVAL_MS)
+ runCurrent()
+ assertEquals(listOf("http://192.168.1.5:8090"), discovery.servers.value.map { it.url })
+ discovery.stop()
+ }
+
+ @Test
+ fun listedRowSurvivesAMissedRecheckAndGoesAfterThreeOrWhenLost() = runTest(dispatcher) {
+ identities["http://192.168.1.5:8090"] = "srv-a"
+ val discovery = discovery()
+ discovery.start()
+ resolve("a", ad("srv-a", "192.168.1.5"))
+ assertEquals(1, discovery.servers.value.size)
+
+ identities.clear()
+ repeat(LanServerDiscovery.MAX_MISSES - 1) {
+ advanceTimeBy(LanServerDiscovery.REFRESH_INTERVAL_MS)
+ runCurrent()
+ assertEquals(1, discovery.servers.value.size, "kept after ${it + 1} missed re-check(s)")
+ }
+ advanceTimeBy(LanServerDiscovery.REFRESH_INTERVAL_MS)
+ runCurrent()
+ assertEquals(emptyList(), discovery.servers.value)
+
+ identities["http://192.168.1.5:8090"] = "srv-a"
+ resolve("b", ad("srv-a", "192.168.1.5"))
+ assertEquals(1, discovery.servers.value.size)
+ browser.events!!.gone("b")
+ runCurrent()
+ assertEquals(emptyList(), discovery.servers.value)
+ discovery.stop()
+ }
+
+ @Test
+ fun aConfirmationStillRunningWhenTheResultIsLostDoesNotRestoreIt() = runTest(dispatcher) {
+ val answer = CompletableDeferred()
+ val discovery = LanServerDiscovery(
+ browser = browser,
+ probeIdentity = { answer.await() },
+ fetchName = { null },
+ dispatcher = dispatcher,
+ )
+ discovery.start()
+ resolve("a", ad("srv-a", "192.168.1.5"))
+ browser.events!!.gone("a")
+ runCurrent()
+
+ answer.complete(ServerIdentityProbe.Identity("srv-a"))
+ runCurrent()
+
+ assertEquals(emptyList(), discovery.servers.value)
+ discovery.stop()
+ }
+
+ @Test
+ fun stopClearsTheListAndDropsLateEvents() = runTest(dispatcher) {
+ identities["http://192.168.1.5:8090"] = "srv-a"
+ val discovery = discovery()
+ discovery.start()
+ val firstSession = browser.events!!
+ resolve("a", ad("srv-a", "192.168.1.5"))
+ assertEquals(1, discovery.servers.value.size)
+
+ discovery.stop()
+ assertEquals(emptyList(), discovery.servers.value)
+
+ discovery.start()
+ firstSession.resolved("a", ad("srv-a", "192.168.1.5"))
+ runCurrent()
+ assertEquals(emptyList(), discovery.servers.value)
+ assertEquals(2, browser.starts)
+ discovery.stop()
+ }
+
+ @Test
+ fun failedBrowseRetriesOnceQuicklyThenOnRefresh() = runTest(dispatcher) {
+ val discovery = discovery()
+ discovery.start()
+ browser.events!!.failed()
+ runCurrent()
+ assertEquals(emptyList(), discovery.servers.value)
+
+ advanceTimeBy(LanServerDiscovery.BROWSE_RETRY_DELAY_MS + 1)
+ assertEquals(2, browser.starts)
+
+ browser.events!!.failed()
+ runCurrent()
+ advanceTimeBy(LanServerDiscovery.BROWSE_RETRY_DELAY_MS + 1)
+ assertEquals(2, browser.starts, "only one quick retry per visit")
+ advanceTimeBy(LanServerDiscovery.REFRESH_INTERVAL_MS)
+ runCurrent()
+ assertEquals(3, browser.starts)
+ discovery.stop()
+ }
+
+ @Test
+ fun eventsFromABrowseThatWasRestartedAreDropped() = runTest(dispatcher) {
+ identities["http://192.168.1.5:8090"] = "srv-a"
+ val discovery = discovery()
+ discovery.start()
+ val failedBrowse = browser.events!!
+ failedBrowse.failed()
+ runCurrent()
+ advanceTimeBy(LanServerDiscovery.BROWSE_RETRY_DELAY_MS + 1)
+ assertEquals(2, browser.starts)
+
+ failedBrowse.resolved("a", ad("srv-a", "192.168.1.5"))
+ runCurrent()
+ assertEquals(emptyList(), discovery.servers.value)
+
+ browser.events!!.resolved("a", ad("srv-a", "192.168.1.5"))
+ runCurrent()
+ assertEquals(1, discovery.servers.value.size)
+ discovery.stop()
+ }
+
+ @Test
+ fun aMovedAddressIsNotListedUnderTheOldOneWhileItIsChecked() = runTest(dispatcher) {
+ val answer = CompletableDeferred()
+ val discovery = LanServerDiscovery(
+ browser = browser,
+ probeIdentity = { url ->
+ if (url == "http://192.168.1.5:8090") ServerIdentityProbe.Identity("srv-a") else answer.await()
+ },
+ fetchName = { null },
+ dispatcher = dispatcher,
+ )
+ discovery.start()
+ resolve("a", ad("srv-a", "192.168.1.5"))
+ assertEquals(listOf("http://192.168.1.5:8090"), discovery.servers.value.map { it.url })
+
+ // A new lease: the advertisement now names .9 only.
+ resolve("a", ad("srv-a", "192.168.1.9"))
+ assertEquals(emptyList(), discovery.servers.value)
+
+ answer.complete(ServerIdentityProbe.Identity("srv-a"))
+ runCurrent()
+ assertEquals(listOf("http://192.168.1.9:8090"), discovery.servers.value.map { it.url })
+ discovery.stop()
+ }
+
+ @Test
+ fun aResolveWithNoUsableAddressKeepsTheListedRow() = runTest(dispatcher) {
+ identities["http://192.168.1.5:8090"] = "srv-a"
+ val discovery = discovery()
+ discovery.start()
+ resolve("a", ad("srv-a", "192.168.1.5"))
+
+ resolve("a", ad("srv-a", "fe80::1%wlan0"))
+
+ assertEquals(listOf("http://192.168.1.5:8090"), discovery.servers.value.map { it.url })
+ discovery.stop()
+ }
+
+ @Test
+ fun advertisedIdIgnoresOtherKeysAndBlankValues() {
+ assertEquals(
+ "srv",
+ advertisedServerId(mapOf("v" to "9".encodeToByteArray(), "x" to null, "ID" to " srv ".encodeToByteArray())),
+ )
+ assertNull(advertisedServerId(mapOf("id" to " ".encodeToByteArray())))
+ assertNull(advertisedServerId(mapOf("id" to null)))
+ assertNull(advertisedServerId(mapOf("v" to "1".encodeToByteArray())))
+ }
+
+ @Test
+ fun originsPreferIpv4AndBracketRoutableIpv6() {
+ assertEquals(
+ listOf("http://192.168.1.5:8091", "http://[fd00::5]:8091"),
+ lanOrigins(listOf("fd00::5%eth0", "fe80::abcd%wlan0", "/192.168.1.5", "192.168.1.5"), 8091),
+ )
+ assertTrue(lanOrigins(listOf("fe80::1"), 8090).isEmpty())
+ assertTrue(lanOrigins(listOf("192.168.1.5"), 0).isEmpty())
+ assertTrue(lanOrigins(listOf("prairie-abc.local"), 8090).isEmpty())
+ assertTrue(lanOrigins(listOf("127.0.0.1", "0.0.0.0", "224.0.0.251", "::1", "::", "ff02::fb"), 8090).isEmpty())
+ }
+}
diff --git a/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/images/SharedDownloadFetcherFactoryTest.kt b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/images/SharedDownloadFetcherFactoryTest.kt
new file mode 100644
index 0000000000..4298f02813
--- /dev/null
+++ b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/images/SharedDownloadFetcherFactoryTest.kt
@@ -0,0 +1,126 @@
+package org.prairieserver.prairie.common.images
+
+import android.content.Context
+import android.graphics.Bitmap
+import androidx.test.core.app.ApplicationProvider
+import coil3.ImageLoader
+import coil3.decode.DataSource
+import coil3.request.CachePolicy
+import coil3.request.ErrorResult
+import coil3.request.ImageRequest
+import coil3.request.SuccessResult
+import kotlinx.coroutines.async
+import kotlinx.coroutines.awaitAll
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.runBlocking
+import kotlinx.coroutines.withTimeout
+import okhttp3.mockwebserver.MockResponse
+import okhttp3.mockwebserver.MockWebServer
+import okio.Buffer
+import org.junit.After
+import org.junit.Before
+import org.junit.runner.RunWith
+import org.robolectric.RobolectricTestRunner
+import org.robolectric.annotation.Config
+import java.io.ByteArrayOutputStream
+import java.util.concurrent.TimeUnit
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertIs
+
+@RunWith(RobolectricTestRunner::class)
+@Config(sdk = [28])
+class SharedDownloadFetcherFactoryTest {
+ private val context = ApplicationProvider.getApplicationContext()
+ private val server = MockWebServer()
+ private lateinit var loader: ImageLoader
+
+ @Before
+ fun setUp() {
+ server.start()
+ loader = buildPrairieImageLoader(context, java.nio.file.Files.createTempDirectory("shared-download").toFile())
+ }
+
+ @After
+ fun tearDown() {
+ loader.shutdown()
+ server.shutdown()
+ }
+
+ @Test
+ fun concurrentRequestsForOneUrlShareADownload() = runBlocking {
+ server.enqueue(slowPng())
+ val url = server.url("/backdrop.png").toString()
+
+ // The detail page's prefetch, hero and blurred surface: same URL, different sizes.
+ val results = listOf(1080, 640, 256).map { size ->
+ async { loader.execute(ImageRequest.Builder(context).data(url).size(size).build()) }
+ }.awaitAll()
+
+ results.forEach { assertIs(it) }
+ assertEquals(1, server.requestCount)
+ }
+
+ @Test
+ fun waiterStillLoadsWhenTheFirstRequestIsCancelled() = runBlocking {
+ server.enqueue(slowPng())
+ server.enqueue(slowPng())
+ val url = server.url("/backdrop.png").toString()
+
+ val first = async { loader.execute(ImageRequest.Builder(context).data(url).size(1080).build()) }
+ withTimeout(5_000) { while (server.requestCount == 0) delay(10) }
+ val second = async { loader.execute(ImageRequest.Builder(context).data(url).size(256).build()) }
+ delay(100)
+ first.cancel()
+
+ assertIs(withTimeout(5_000) { second.await() })
+ }
+
+ @Test
+ fun waiterDownloadsItselfWhenTheFirstRequestFails() = runBlocking {
+ server.enqueue(MockResponse().setResponseCode(500).setBodyDelay(400, TimeUnit.MILLISECONDS))
+ server.enqueue(slowPng())
+ val url = server.url("/backdrop.png").toString()
+
+ val first = async { loader.execute(ImageRequest.Builder(context).data(url).size(1080).build()) }
+ withTimeout(5_000) { while (server.requestCount == 0) delay(10) }
+ val second = async { loader.execute(ImageRequest.Builder(context).data(url).size(256).build()) }
+
+ assertIs(first.await())
+ assertIs(second.await())
+ assertEquals(2, server.requestCount)
+ }
+
+ @Test
+ fun memoryCacheHitDoesNotWaitForAnotherDownload() = runBlocking {
+ // In memory only, so the next fetch for this URL must download.
+ server.enqueue(png())
+ server.enqueue(slowPng(bodyDelayMillis = 3_000))
+ val url = server.url("/backdrop.png").toString()
+ assertIs(
+ loader.execute(ImageRequest.Builder(context).data(url).diskCachePolicy(CachePolicy.DISABLED).build()),
+ )
+
+ val download = async {
+ loader.execute(ImageRequest.Builder(context).data(url).memoryCacheKey("other-size").build())
+ }
+ withTimeout(5_000) { while (server.requestCount < 2) delay(10) }
+
+ val fromMemory = withTimeout(1_000) { loader.execute(ImageRequest.Builder(context).data(url).build()) }
+ assertIs(fromMemory)
+ assertEquals(DataSource.MEMORY_CACHE, fromMemory.dataSource)
+ download.cancel()
+ }
+
+ private fun png(): MockResponse {
+ val png = ByteArrayOutputStream().apply {
+ Bitmap.createBitmap(2, 2, Bitmap.Config.ARGB_8888).compress(Bitmap.CompressFormat.PNG, 100, this)
+ }.toByteArray()
+ return MockResponse()
+ .setHeader("Content-Type", "image/png")
+ .setBody(Buffer().write(png))
+ }
+
+ private fun slowPng(bodyDelayMillis: Long = 400): MockResponse =
+ png().setBodyDelay(bodyDelayMillis, TimeUnit.MILLISECONDS)
+}
diff --git a/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/network/LocalRouteTransportTest.kt b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/network/LocalRouteTransportTest.kt
new file mode 100644
index 0000000000..65ed5cf367
--- /dev/null
+++ b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/network/LocalRouteTransportTest.kt
@@ -0,0 +1,349 @@
+package org.prairieserver.prairie.common.network
+
+import java.io.IOException
+import java.io.InterruptedIOException
+import java.math.BigInteger
+import java.net.InetAddress
+import java.security.KeyPair
+import java.security.KeyPairGenerator
+import java.security.KeyStore
+import java.security.MessageDigest
+import java.security.Signature
+import java.security.cert.CertificateFactory
+import java.security.cert.X509Certificate
+import java.security.spec.ECGenParameterSpec
+import java.util.Base64
+import java.util.Date
+import java.util.concurrent.TimeUnit
+import javax.net.ssl.KeyManagerFactory
+import javax.net.ssl.SSLContext
+import javax.net.ssl.SSLSocketFactory
+import kotlin.test.AfterTest
+import kotlin.test.BeforeTest
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertFailsWith
+import kotlin.test.assertNull
+import kotlin.test.assertTrue
+import kotlinx.coroutines.runBlocking
+import okhttp3.MediaType.Companion.toMediaType
+import okhttp3.OkHttpClient
+import okhttp3.Request
+import okhttp3.RequestBody.Companion.toRequestBody
+import okhttp3.mockwebserver.MockResponse
+import okhttp3.mockwebserver.MockWebServer
+import okhttp3.mockwebserver.SocketPolicy
+import org.bouncycastle.asn1.ASN1Integer
+import org.bouncycastle.asn1.DERBitString
+import org.bouncycastle.asn1.DERSequence
+import org.bouncycastle.asn1.x500.X500Name
+import org.bouncycastle.asn1.x509.AlgorithmIdentifier
+import org.bouncycastle.asn1.x509.SubjectPublicKeyInfo
+import org.bouncycastle.asn1.x509.Time
+import org.bouncycastle.asn1.x509.V3TBSCertificateGenerator
+import org.bouncycastle.asn1.x9.X9ObjectIdentifiers
+import org.prairieserver.prairie.network.HttpOrigin
+import org.prairieserver.prairie.network.localroute.LocalRoute
+import org.prairieserver.prairie.network.localroute.LocalRouteCheck
+import org.prairieserver.prairie.network.localroute.LocalRouteHeaders
+import org.prairieserver.prairie.network.localroute.LocalRouteOffer
+import org.prairieserver.prairie.network.localroute.LocalRouteProbe
+import org.prairieserver.prairie.network.localroute.LocalRouteState
+import org.prairieserver.prairie.network.localroute.LocalRouteTls
+import org.prairieserver.prairie.network.localroute.localRoute
+
+/**
+ * The local route's transport against real TLS: requests move only to a
+ * candidate presenting the pinned key, report the saved address, and go again
+ * to the saved address only when that is safe.
+ */
+class LocalRouteTransportTest {
+ private val server = TestCertificate("prairie-lan-server")
+ private val impostor = TestCertificate("prairie-lan-impostor")
+
+ private lateinit var saved: MockWebServer
+ private lateinit var lan: MockWebServer
+ private val state = LocalRouteState()
+ private val tls = LocalRouteTls(state)
+
+ @BeforeTest
+ fun setUp() {
+ saved = MockWebServer().apply { start(InetAddress.getByName("127.0.0.1"), 0) }
+ lan = MockWebServer().apply { useHttps(server.serverSocketFactory(), false) }
+ lan.start(InetAddress.getByName("127.0.0.1"), 0)
+ }
+
+ @AfterTest
+ fun tearDown() {
+ runCatching { saved.shutdown() }
+ runCatching { lan.shutdown() }
+ }
+
+ @Test
+ fun aRequestTakesTheRouteAndReportsTheSavedAddress() {
+ activate(pin = server.pin)
+ lan.enqueue(MockResponse().setBody("from lan"))
+
+ client().newCall(get("/api/v2/home?limit=3").header("Authorization", "Bearer token").build()).execute().use { response ->
+ assertEquals("from lan", response.body!!.string())
+ assertEquals(savedUrl("/api/v2/home?limit=3"), response.request.url.toString())
+ }
+ val received = lan.takeRequest()
+ assertEquals("/api/v2/home?limit=3", received.path)
+ assertEquals("Bearer token", received.getHeader("Authorization"))
+ assertEquals(0, saved.requestCount)
+ }
+
+ @Test
+ fun aRefusedRouteGoesToTheSavedAddressAndIsDroppedAtOnce() {
+ activate(pin = server.pin)
+ lan.shutdown()
+ saved.enqueue(MockResponse().setBody("from saved"))
+
+ client().newCall(get("/api/v2/home").build()).execute().use { assertEquals("from saved", it.body!!.string()) }
+ assertNull(state.active.value)
+ }
+
+ @Test
+ fun aKeyOtherThanThePinNeverReceivesTheRequest() {
+ lan.shutdown()
+ lan = MockWebServer().apply { useHttps(impostor.serverSocketFactory(), false) }
+ lan.start(InetAddress.getByName("127.0.0.1"), 0)
+ activate(pin = server.pin)
+ saved.enqueue(MockResponse().setBody("from saved"))
+
+ client().newCall(get("/api/v2/home").header("Authorization", "Bearer token").build()).execute().use {
+ assertEquals("from saved", it.body!!.string())
+ }
+ assertEquals(0, lan.requestCount)
+ assertNull(state.active.value)
+ }
+
+ @Test
+ fun aPooledConnectionIsNotReusedOnceItsKeyIsNoLongerThePin() {
+ val route = activate(pin = server.pin)
+ val client = client()
+ lan.enqueue(MockResponse().setBody("first"))
+ client.newCall(get("/api/v2/home").build()).execute().use { assertEquals("first", it.body!!.string()) }
+
+ // The key rotated: the warm connection still presents the old one.
+ state.trust(route.localOrigin.host, route.localOrigin.port, impostor.pin)
+ state.publish(route.copy(generation = route.generation + 1))
+ saved.enqueue(MockResponse().setBody("from saved"))
+ client.newCall(get("/api/v2/home").build()).execute().use { assertEquals("from saved", it.body!!.string()) }
+ assertEquals(1, lan.requestCount)
+ }
+
+ @Test
+ fun aMutationThatMayHaveArrivedIsNotRepeated() {
+ activate(pin = server.pin)
+ lan.enqueue(MockResponse().setSocketPolicy(SocketPolicy.DISCONNECT_AFTER_REQUEST))
+ val client = client { retryOnConnectionFailure(false) }
+
+ assertFailsWith { client.newCall(post("/api/v2/playback/session")).execute() }
+ assertEquals(1, lan.requestCount)
+ assertEquals(0, saved.requestCount)
+ assertNull(state.active.value)
+ }
+
+ @Test
+ fun aMutationThatCouldNotBeSentGoesToTheSavedAddress() {
+ activate(pin = server.pin)
+ lan.shutdown()
+ saved.enqueue(MockResponse().setResponseCode(201))
+
+ client().newCall(post("/api/v2/playback/session")).execute().use { assertEquals(201, it.code) }
+ assertEquals("""{"a":1}""", saved.takeRequest().body.readUtf8())
+ }
+
+ @Test
+ fun aLoadThatFailsBeforeItsBodyIsHandedOnLoadsFromTheSavedAddress() {
+ activate(pin = server.pin)
+ lan.enqueue(MockResponse().setBody("x".repeat(64 * 1024)).setSocketPolicy(SocketPolicy.DISCONNECT_DURING_RESPONSE_BODY))
+ saved.enqueue(MockResponse().setBody("from saved"))
+
+ client().newCall(get("/api/v2/artwork/1").build()).execute().use { assertEquals("from saved", it.body!!.string()) }
+ assertNull(state.active.value)
+ }
+
+ @Test
+ fun anInterruptedReadLeavesTheRouteInUse() {
+ val route = activate(pin = server.pin)
+ lan.enqueue(MockResponse().setBody("x".repeat(256 * 1024)).throttleBody(1024, 50, TimeUnit.MILLISECONDS))
+ val response = client(media = true)
+ .newCall(get("/api/v2/stream/s").header(LocalRouteHeaders.MEDIA, route.generation.toString()).build())
+ .execute()
+ var failure: Throwable? = null
+ val reader = Thread {
+ failure = runCatching { response.body!!.source().readByteString() }.exceptionOrNull()
+ }
+ reader.start()
+ Thread.sleep(200)
+ // Media3 abandons a load (a seek) by interrupting its loading thread.
+ reader.interrupt()
+ reader.join(5_000)
+ response.close()
+
+ assertTrue(failure is InterruptedIOException, "expected an interrupted read, got $failure")
+ assertEquals(route, state.active.value)
+ }
+
+ @Test
+ fun signInWebSocketsAndMarkedRequestsStayOnTheSavedAddress() {
+ activate(pin = server.pin)
+ val client = client()
+ repeat(3) { saved.enqueue(MockResponse()) }
+
+ client.newCall(post("/api/v2/auth/refresh")).execute().close()
+ client.newCall(get("/api/v2/events").header("Upgrade", "websocket").build()).execute().close()
+ client.newCall(get("/api/v2/downloads/1/file").header(LocalRouteHeaders.SAVED_ADDRESS, "1").build()).execute().close()
+
+ assertEquals(3, saved.requestCount)
+ assertEquals(0, lan.requestCount)
+ repeat(2) { saved.takeRequest() }
+ assertNull(saved.takeRequest().getHeader(LocalRouteHeaders.SAVED_ADDRESS))
+ }
+
+ @Test
+ fun mediaTakesOnlyTheRouteItStartedOn() {
+ val route = activate(pin = server.pin)
+ val client = client(media = true)
+ saved.enqueue(MockResponse().setBody("unmarked"))
+ lan.enqueue(MockResponse().setBody("current"))
+ saved.enqueue(MockResponse().setBody("older"))
+
+ client.newCall(get("/api/v2/stream/s").build()).execute().use { assertEquals("unmarked", it.body!!.string()) }
+ client.newCall(get("/api/v2/stream/s").header(LocalRouteHeaders.MEDIA, route.generation.toString()).build())
+ .execute().use { assertEquals("current", it.body!!.string()) }
+ client.newCall(get("/api/v2/stream/s").header(LocalRouteHeaders.MEDIA, (route.generation - 1).toString()).build())
+ .execute().use { assertEquals("older", it.body!!.string()) }
+ assertNull(lan.takeRequest().getHeader(LocalRouteHeaders.MEDIA))
+ }
+
+ @Test
+ fun aCandidateIsConfirmedOnlyWithThePinnedKeyAndTheSavedServersIdentity() = runBlocking {
+ val probe = LocalRouteProbe(probeTimeoutMs = 20_000)
+ val origin = HttpOrigin("https", "127.0.0.1", lan.port)
+ lan.enqueue(MockResponse().setBody("""{"server_id":"deployment-1"}"""))
+ assertEquals(LocalRouteCheck.CONFIRMED, probe.confirms(origin, server.pin, "deployment-1"))
+
+ lan.enqueue(MockResponse().setBody("""{"server_id":"deployment-2"}"""))
+ assertEquals(LocalRouteCheck.UNCONFIRMED, probe.confirms(origin, server.pin, "deployment-1"))
+
+ // Another key: the handshake fails and no request is sent.
+ val count = lan.requestCount
+ assertEquals(LocalRouteCheck.OTHER_KEY, probe.confirms(origin, impostor.pin, "deployment-1"))
+ assertEquals(count, lan.requestCount)
+
+ // A redirect is not followed.
+ lan.enqueue(MockResponse().setResponseCode(302).setHeader("Location", savedUrl("/api/v2/system/identity")))
+ assertEquals(LocalRouteCheck.UNCONFIRMED, probe.confirms(origin, server.pin, "deployment-1"))
+ assertEquals(0, saved.requestCount)
+ }
+
+ @Test
+ fun thePinIsLearnedOnlyFromTheSavedAddressWithoutRedirects() = runBlocking {
+ val probe = LocalRouteProbe(probeTimeoutMs = 20_000)
+ val elsewhere = MockWebServer().apply { start(InetAddress.getByName("127.0.0.1"), 0) }
+ try {
+ saved.enqueue(MockResponse().setResponseCode(307).setHeader("Location", elsewhere.url("/api/v2/system/connections")))
+ assertEquals(LocalRouteOffer.Unreadable, probe.readOffer(savedUrl(""), "token"))
+ assertEquals(0, elsewhere.requestCount)
+
+ saved.enqueue(
+ MockResponse().setBody(
+ """{"state":"available","server_id":"deployment-1","endpoints":[],""" +
+ """"local_route":{"spki_sha256":"${server.pin}","url":"http://192.168.1.20:8080"}}""",
+ ),
+ )
+ assertEquals(
+ LocalRouteOffer.Offered("deployment-1", server.pin, "http://192.168.1.20:8080"),
+ probe.readOffer(savedUrl(""), "token"),
+ )
+ saved.takeRequest()
+ assertEquals("Bearer token", saved.takeRequest().getHeader("Authorization"))
+ } finally {
+ elsewhere.shutdown()
+ }
+ }
+
+ @Test
+ fun thePinIsTheHashOfTheSubjectPublicKeyInfo() {
+ val expected = Base64.getEncoder().encodeToString(
+ MessageDigest.getInstance("SHA-256").digest(server.certificate.publicKey.encoded),
+ )
+ assertEquals(expected, LocalRouteTls.spkiPin(server.certificate))
+ }
+
+ private fun activate(pin: String): LocalRoute {
+ val route = LocalRoute(
+ serverId = "server",
+ savedOrigin = HttpOrigin("http", "127.0.0.1", saved.port),
+ localOrigin = HttpOrigin("https", "127.0.0.1", lan.port),
+ generation = 7,
+ )
+ state.trust(route.localOrigin.host, route.localOrigin.port, pin)
+ state.publish(route)
+ return route
+ }
+
+ private fun client(media: Boolean = false, configure: OkHttpClient.Builder.() -> Unit = {}): OkHttpClient =
+ OkHttpClient.Builder()
+ .readTimeout(5, TimeUnit.SECONDS)
+ .apply(configure)
+ .localRoute(state, tls, media)
+ .build()
+
+ private fun savedUrl(path: String) = "http://127.0.0.1:${saved.port}$path"
+
+ private fun get(path: String) = Request.Builder().url(savedUrl(path))
+
+ private fun post(path: String) =
+ Request.Builder().url(savedUrl(path)).post("""{"a":1}""".toRequestBody("application/json".toMediaType())).build()
+}
+
+/** A throwaway self-signed P-256 certificate, the kind of key the server derives. */
+private class TestCertificate(commonName: String) {
+ val keys: KeyPair = KeyPairGenerator.getInstance("EC")
+ .apply { initialize(ECGenParameterSpec("secp256r1")) }
+ .generateKeyPair()
+
+ val certificate: X509Certificate = run {
+ val algorithm = AlgorithmIdentifier(X9ObjectIdentifiers.ecdsa_with_SHA256)
+ val name = X500Name("CN=$commonName")
+ val tbs = V3TBSCertificateGenerator().apply {
+ setSerialNumber(ASN1Integer(BigInteger.valueOf(System.nanoTime())))
+ setIssuer(name)
+ setSubject(name)
+ setStartDate(Time(Date(System.currentTimeMillis() - DAY_MS)))
+ setEndDate(Time(Date(System.currentTimeMillis() + DAY_MS)))
+ setSubjectPublicKeyInfo(SubjectPublicKeyInfo.getInstance(keys.public.encoded))
+ setSignature(algorithm)
+ }.generateTBSCertificate()
+ val signature = Signature.getInstance("SHA256withECDSA").run {
+ initSign(keys.private)
+ update(tbs.encoded)
+ sign()
+ }
+ val der = DERSequence(arrayOf(tbs, algorithm, DERBitString(signature))).encoded
+ CertificateFactory.getInstance("X.509").generateCertificate(der.inputStream()) as X509Certificate
+ }
+
+ val pin: String = LocalRouteTls.spkiPin(certificate)!!
+
+ fun serverSocketFactory(): SSLSocketFactory {
+ val password = "test".toCharArray()
+ val store = KeyStore.getInstance("PKCS12").apply {
+ load(null, null)
+ setKeyEntry("key", keys.private, password, arrayOf(certificate))
+ }
+ val keyManagers = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm())
+ .apply { init(store, password) }
+ .keyManagers
+ return SSLContext.getInstance("TLS").apply { init(keyManagers, null, null) }.socketFactory
+ }
+
+ private companion object {
+ const val DAY_MS = 86_400_000L
+ }
+}
diff --git a/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/HlsSubtitleRenditionTest.kt b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/HlsSubtitleRenditionTest.kt
new file mode 100644
index 0000000000..331db3d5a2
--- /dev/null
+++ b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/HlsSubtitleRenditionTest.kt
@@ -0,0 +1,97 @@
+package org.prairieserver.prairie.common.player
+
+import org.prairieserver.prairie.model.playback.*
+import org.prairieserver.prairie.playback.playbackSubtitleIdentity
+import kotlin.test.*
+
+class HlsSubtitleRenditionTest {
+ private val selectedRow = PlaybackSubtitleInventoryItemV3(
+ trackId = "file:42:subtitle:0", combinedIndex = 0, source = "embedded",
+ codec = "subrip", language = "eng", label = "English", delivery = "sidecar",
+ url = "/stream/session/subtitles/0.vtt?file_id=42&embedded_stream_index=3",
+ )
+ private val otherRow = selectedRow.copy(
+ trackId = "file:42:subtitle:1", combinedIndex = 1, language = "fra", label = "French",
+ url = "/stream/session/subtitles/1.vtt",
+ )
+ private val plan = PlaybackPlanV3(
+ planId = "rendition", planAttemptKey = "v3:rendition", sessionId = "session",
+ delivery = PlaybackDelivery.SERVER_TRANSCODE_HLS,
+ stream = PlaybackStreamV3(url = "/playback/transcode/session/master.m3u8", protocol = PlaybackStreamProtocol.HLS),
+ selectedTracks = SelectedPlaybackTracksV3(subtitle = PlaybackTrackIdentityV3(selectedRow.trackId, 0)),
+ subtitle = PlaybackSubtitleDecisionV3(
+ mode = PlaybackSubtitleModeV3.RENDER, trackId = selectedRow.trackId,
+ hlsRendition = PlaybackSubtitleHlsRenditionV3("subs", "Subtitles", "vtt", "text/vtt"),
+ inventory = listOf(selectedRow, otherRow),
+ ),
+ decisionReason = "rendition",
+ )
+ private val rendition = SubtitleHlsRendition("subs", "Subtitles")
+
+ private fun track(index: Int, id: String?, label: String? = null, language: String? = null) =
+ MountedSubtitleTrack(index, id, label, language, "text/vtt", false, false)
+
+ @Test fun selectedRowCarriesTheRenditionAndNoUrlToMount() {
+ val session = plan.toSessionResponse("session", "profile", 42)
+ val rows = session.subtitleUrls!!
+ val selected = rows.single { it.index == 0 }
+ assertEquals(rendition, selected.hlsRendition)
+ assertEquals("", selected.url)
+ val other = rows.single { it.index == 1 }
+ assertNull(other.hlsRendition)
+ assertEquals(otherRow.url, other.url)
+
+ val identity = assertIs(playbackSubtitleIdentity(selected))
+ assertEquals(rendition, identity.hlsRendition)
+ assertTrue(subtitlesForVideoMediaMount(rows, session.playbackPlan, identity).isEmpty())
+ assertTrue(subtitlesForVideoMediaMount(rows, session.playbackPlan, identity, preferMuxedTracks = true).isEmpty())
+ // Even a stale sidecar identity for the selected ordinal mounts nothing.
+ assertTrue(subtitlesForVideoMediaMount(rows, session.playbackPlan, SubtitleIdentity.ServerSidecar(0)).isEmpty())
+ }
+
+ @Test fun plansWithoutARenditionKeepTheirSidecar() {
+ val sidecarPlan = plan.copy(
+ subtitle = plan.subtitle.copy(
+ hlsRendition = null,
+ artifact = PlaybackSubtitleArtifactV3(selectedRow.url!!, "text/vtt", "vtt"),
+ ),
+ )
+ val session = sidecarPlan.toSessionResponse("session", "profile", 42)
+ val selected = session.subtitleUrls!!.single { it.index == 0 }
+ assertNull(selected.hlsRendition)
+ assertEquals(selectedRow.url, selected.url)
+ val identity = assertIs(playbackSubtitleIdentity(selected))
+ assertEquals(listOf(selectedRow.url), subtitlesForVideoMediaMount(session.subtitleUrls!!, session.playbackPlan, identity).map { it.url })
+
+ val off = plan.copy(
+ selectedTracks = SelectedPlaybackTracksV3(),
+ subtitle = plan.subtitle.copy(mode = PlaybackSubtitleModeV3.OFF, trackId = null, hlsRendition = null),
+ ).toSessionResponse("session", "profile", 42)
+ assertTrue(off.subtitleUrls!!.all { it.hlsRendition == null && it.url.isNotBlank() })
+ }
+
+ @Test fun resolvesTheManifestRenditionByItsMedia3Id() {
+ val identity = SubtitleIdentity.Embedded(0, SubtitleMediaIdentity(trackId = selectedRow.trackId, language = "eng"), hlsRendition = rendition)
+ val cea608 = track(0, "0:cc:1", language = "eng")
+ assertEquals(1, resolveMountedSubtitle(identity, listOf(cea608, track(1, "subs:Subtitles", "Subtitles")))?.track?.index)
+ // A merged source prefixes the child index.
+ assertEquals(2, resolveMountedSubtitle(identity, listOf(cea608, track(1, "1:prairie-subtitle:1", "French"), track(2, "0:subs:Subtitles", "Subtitles")))?.track?.index)
+ // Language alone never identifies the rendition.
+ assertNull(resolveMountedSubtitle(identity, listOf(cea608, track(1, "1:prairie-subtitle:0", "English", "eng"))))
+ assertNull(resolveMountedSubtitle(identity, listOf(track(0, "subs:Subtitles"), track(1, "1:subs:Subtitles"))))
+ }
+
+ @Test fun fallsBackToTheRenditionNameOnlyWhenItIsUnambiguous() {
+ val identity = SubtitleIdentity.Embedded(0, SubtitleMediaIdentity(), hlsRendition = rendition)
+ assertEquals(1, resolveMountedSubtitle(identity, listOf(track(0, "0:cc:1"), track(1, "subtitle:0", "Subtitles")))?.track?.index)
+ assertNull(resolveMountedSubtitle(identity, listOf(track(0, "a", "Subtitles"), track(1, "b", "Subtitles"))))
+ assertNull(resolveMountedSubtitle(identity, listOf(track(0, "prairie-subtitle:0", "Subtitles"))))
+ }
+
+ @Test fun rowBridgeUsesTheRenditionIdentity() {
+ val session = plan.toSessionResponse("session", "profile", 42)
+ val selected = session.subtitleUrls!!.single { it.index == 0 }
+ assertEquals(3, resolveMountedSubtitle(selected, listOf(track(0, "0:cc:1", language = "eng"), track(3, "subs:Subtitles", "Subtitles")))?.track?.index)
+ assertNull(resolveMountedSubtitle(selected, listOf(track(0, "prairie-subtitle:0", "English", "eng"))))
+ }
+}
diff --git a/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/NetworkQualityFollowerTest.kt b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/NetworkQualityFollowerTest.kt
new file mode 100644
index 0000000000..ef65b38ba9
--- /dev/null
+++ b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/NetworkQualityFollowerTest.kt
@@ -0,0 +1,291 @@
+package org.prairieserver.prairie.common.player
+
+import org.prairieserver.prairie.common.settings.PlayerSettingsStore
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.flow
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.test.TestScope
+import kotlinx.coroutines.test.advanceTimeBy
+import kotlinx.coroutines.test.currentTime
+import kotlinx.coroutines.test.runCurrent
+import kotlinx.coroutines.test.runTest
+import java.io.IOException
+import java.lang.reflect.Proxy
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertFalse
+import kotlin.test.assertTrue
+
+@OptIn(ExperimentalCoroutinesApi::class)
+class NetworkQualityFollowerTest {
+ private val main = PlaybackQualityPair("2160p", 40_000)
+ private val cellular = PlaybackQualityPair("720p", 2_000)
+ private val store = qualityStore(main = main, cellular = cellular)
+
+ @Test
+ fun `mobile data and metered networks use the cellular pair, a VPN only over mobile data`() {
+ assertTrue(snapshot("cellular", metered = true).usesCellularQuality, "cellular")
+ // A carrier that marks its plan unmetered is still mobile data.
+ assertTrue(snapshot("cellular", metered = false).usesCellularQuality, "unmetered cellular")
+ // A phone hotspot, or Wi-Fi the user marked metered.
+ assertTrue(snapshot("wifi", metered = true).usesCellularQuality, "metered Wi-Fi")
+ assertFalse(snapshot("wifi", metered = false).usesCellularQuality, "unmetered Wi-Fi")
+ assertFalse(snapshot("ethernet", metered = false).usesCellularQuality, "ethernet")
+ // Android marks most VPNs metered; Tailscale on home Wi-Fi is not mobile data.
+ assertFalse(snapshot("wifi", metered = true, vpn = true).usesCellularQuality, "VPN over Wi-Fi")
+ assertTrue(snapshot("cellular", metered = true, vpn = true).usesCellularQuality, "VPN over cellular")
+ assertFalse(snapshot("cellular", metered = true).copy(connected = false).usesCellularQuality, "offline")
+ }
+
+ @Test
+ fun `an Auto picked in the player takes the saved cap for the connection`() = runTest {
+ assertEquals(PlaybackQualityPair("auto", 40_000), store.inPlayerQualityPair("auto", cellular = false))
+ assertEquals(PlaybackQualityPair("auto", 2_000), store.inPlayerQualityPair("Auto", cellular = true))
+ // No limit sends no cap.
+ val uncapped = qualityStore(main = PlaybackQualityPair("1080p", null), cellular = cellular)
+ assertEquals(PlaybackQualityPair("auto", null), uncapped.inPlayerQualityPair("auto", cellular = false))
+ // Any other pick keeps the session's cap.
+ assertEquals(null, store.inPlayerQualityPair("1080p-medium", cellular = true))
+ assertEquals(null, store.inPlayerQualityPair(null, cellular = true))
+ }
+
+ @Test
+ fun `the start pair follows the network`() = runTest {
+ assertEquals(cellular, store.qualityPair(cellular = snapshot("wifi", metered = true).usesCellularQuality))
+ assertEquals(main, store.qualityPair(cellular = snapshot("wifi", metered = false).usesCellularQuality))
+ }
+
+ @Test
+ fun `a network change that holds moves the session to the other pair`() = runTest {
+ val network = MutableStateFlow(snapshot("wifi"))
+ var playing = FollowedSession("s1", main)
+ val replans = follow(network, { playing }) { _, pair -> playing = FollowedSession("s1", pair) }
+
+ settle()
+ assertEquals(emptyList(), replans, "already on the Wi-Fi pair")
+
+ network.value = snapshot("cellular", metered = true)
+ advanceTimeBy(4_000)
+ runCurrent()
+ assertEquals(emptyList(), replans, "not settled yet")
+ settle()
+ assertEquals(listOf(cellular), replans)
+
+ network.value = snapshot("wifi")
+ settle()
+ assertEquals(listOf(cellular, main), replans)
+ }
+
+ @Test
+ fun `a brief drop to another network does not replan`() = runTest {
+ val network = MutableStateFlow(snapshot("wifi"))
+ val replans = follow(network, { FollowedSession("s1", main) })
+
+ network.value = snapshot("cellular", metered = true)
+ advanceTimeBy(2_000)
+ network.value = snapshot("wifi")
+ settle()
+
+ assertEquals(emptyList(), replans)
+ }
+
+ @Test
+ fun `moments without a connection are not read as unmetered`() = runTest {
+ val network = MutableStateFlow(snapshot("cellular", metered = true))
+ val replans = follow(network, { FollowedSession("s1", cellular) })
+
+ network.value = PlaybackNetworkSnapshot()
+ settle()
+ network.value = snapshot("cellular", metered = true)
+ settle()
+
+ assertEquals(emptyList(), replans)
+ }
+
+ @Test
+ fun `Auto picked in the player keeps following, with only the cap moving`() = runTest {
+ val network = MutableStateFlow(snapshot("wifi"))
+ // The saved resolution is 4K; the viewer picked Auto over it.
+ var playing = FollowedSession("s1", PlaybackQualityPair("auto", 40_000), autoPicked = true)
+ val replans = follow(network, { playing }) { _, pair -> playing = playing.copy(pair = pair) }
+
+ settle()
+ assertEquals(emptyList(), replans)
+
+ network.value = snapshot("cellular", metered = true)
+ settle()
+ assertEquals(listOf(PlaybackQualityPair("auto", 2_000)), replans)
+
+ network.value = snapshot("wifi")
+ settle()
+ assertEquals(listOf(PlaybackQualityPair("auto", 2_000), PlaybackQualityPair("auto", 40_000)), replans)
+ }
+
+ @Test
+ fun `a fixed rung the viewer picked in the player is not overridden`() = runTest {
+ val network = MutableStateFlow(snapshot("wifi"))
+ // The player reports no followed session once the viewer picks a rung.
+ val replans = follow(network, { null })
+
+ network.value = snapshot("cellular", metered = true)
+ settle()
+
+ assertEquals(emptyList(), replans)
+ }
+
+ @Test
+ fun `a move that does not take is tried again, reading the session afresh, until it takes`() = runTest {
+ val network = MutableStateFlow(snapshot("wifi"))
+ var playing = FollowedSession("s1", main)
+ val attempts = mutableListOf>()
+ backgroundScope.launch {
+ NetworkQualityFollower(network, store).follow({ playing }) { session, pair ->
+ attempts += session to currentTime
+ // The first request fails; then a recovery replaces the
+ // session, still on the Wi-Fi pair; the second try takes.
+ playing = if (attempts.size == 1) FollowedSession("s2", main) else FollowedSession("s2", pair)
+ }
+ }
+ network.value = snapshot("cellular", metered = true)
+ settle()
+ val first = attempts.single().second
+
+ advanceTimeBy(NetworkQualityFollower.RETRY_DELAYS_MS[1])
+ runCurrent()
+ // The retry moves the session playing now, not the one the first try read.
+ assertEquals(listOf("s1", "s2"), attempts.map { it.first.sessionId })
+ assertEquals(first + NetworkQualityFollower.RETRY_DELAYS_MS[1], attempts.last().second)
+
+ advanceTimeBy(120_000)
+ runCurrent()
+ assertEquals(2, attempts.size, "nothing left to move once it took")
+ }
+
+ @Test
+ fun `a settings read that fails is tried again, and following goes on`() = runTest {
+ val network = MutableStateFlow(snapshot("wifi"))
+ var failingReads = 1
+ val flaky = qualityStore(main = main, cellular = cellular) { failingReads-- > 0 }
+ var playing = FollowedSession("s1", main)
+ val replans = mutableListOf()
+ backgroundScope.launch {
+ NetworkQualityFollower(network, flaky).follow({ playing }) { _, pair ->
+ replans += pair
+ playing = FollowedSession("s1", pair)
+ }
+ }
+
+ network.value = snapshot("cellular", metered = true)
+ settle()
+ assertEquals(emptyList(), replans, "the read failed")
+ advanceTimeBy(NetworkQualityFollower.RETRY_DELAYS_MS[1])
+ runCurrent()
+ assertEquals(listOf(cellular), replans)
+
+ network.value = snapshot("wifi")
+ settle()
+ assertEquals(listOf(cellular, main), replans)
+ }
+
+ @Test
+ fun `retries are bounded`() = runTest {
+ val network = MutableStateFlow(snapshot("wifi"))
+ // Every request fails: the session stays on the Wi-Fi pair.
+ val replans = follow(network, { FollowedSession("s1", main) })
+
+ network.value = snapshot("cellular", metered = true)
+ settle()
+ advanceTimeBy(10 * 60_000)
+ runCurrent()
+
+ assertEquals(List(NetworkQualityFollower.RETRY_DELAYS_MS.size) { cellular }, replans)
+ }
+
+ @Test
+ fun `a newer network supersedes a pending retry`() = runTest {
+ val network = MutableStateFlow(snapshot("wifi"))
+ val replans = follow(network, { FollowedSession("s1", main) })
+
+ network.value = snapshot("cellular", metered = true)
+ settle()
+ assertEquals(listOf(cellular), replans)
+
+ // Back on Wi-Fi before the retry: the session already plays its pair.
+ network.value = snapshot("wifi")
+ advanceTimeBy(10 * 60_000)
+ runCurrent()
+ assertEquals(listOf(cellular), replans)
+ }
+
+ @Test
+ fun `a retry stops once the viewer picks a fixed rung`() = runTest {
+ val network = MutableStateFlow(snapshot("wifi"))
+ var playing: FollowedSession? = FollowedSession("s1", main)
+ val replans = follow(network, { playing }) { _, _ -> playing = null }
+
+ network.value = snapshot("cellular", metered = true)
+ settle()
+ advanceTimeBy(10 * 60_000)
+ runCurrent()
+
+ assertEquals(listOf(cellular), replans)
+ }
+
+ private fun TestScope.follow(
+ network: Flow,
+ playing: () -> FollowedSession?,
+ onReplan: (FollowedSession, PlaybackQualityPair) -> Unit = { _, _ -> },
+ ): List {
+ val replans = mutableListOf()
+ // Collects forever, so it runs where the test does not wait for it.
+ backgroundScope.launch {
+ NetworkQualityFollower(network, store).follow(playing) { session, pair ->
+ replans += pair
+ onReplan(session, pair)
+ }
+ }
+ return replans
+ }
+
+ private fun TestScope.settle() {
+ advanceTimeBy(NetworkQualityFollower.DEFAULT_SETTLE_MS + 1_000)
+ runCurrent()
+ }
+
+ private fun snapshot(transport: String, metered: Boolean = false, vpn: Boolean = false) =
+ PlaybackNetworkSnapshot(
+ connected = true,
+ validated = true,
+ metered = metered,
+ transport = transport,
+ cellular = transport == "cellular",
+ vpn = vpn,
+ )
+
+ /**
+ * Only the four quality flows answer; anything else fails the test. A
+ * read throws while [readFails] says so, as DataStore does on a disk error.
+ */
+ private fun qualityStore(
+ main: PlaybackQualityPair,
+ cellular: PlaybackQualityPair,
+ readFails: () -> Boolean = { false },
+ ): PlayerSettingsStore {
+ val unused = Proxy.newProxyInstance(
+ PlayerSettingsStore::class.java.classLoader,
+ arrayOf(PlayerSettingsStore::class.java),
+ ) { _, method, _ -> throw UnsupportedOperationException(method.name) } as PlayerSettingsStore
+ fun read(value: T): Flow = flow {
+ if (readFails()) throw IOException("settings read failed")
+ emit(value)
+ }
+ return object : PlayerSettingsStore by unused {
+ override val preferredQualityFlow = read(main.qualityPreference)
+ override val maxBitrateKbpsFlow = read(main.bandwidthCapKbps)
+ override val cellularPreferredQualityFlow = read(cellular.qualityPreference)
+ override val cellularMaxBitrateKbpsFlow = read(cellular.bandwidthCapKbps)
+ }
+ }
+}
diff --git a/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/PlaybackCapabilityDetectorDolbyVisionTest.kt b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/PlaybackCapabilityDetectorDolbyVisionTest.kt
index 03e356bb74..31887c2ebe 100644
--- a/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/PlaybackCapabilityDetectorDolbyVisionTest.kt
+++ b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/PlaybackCapabilityDetectorDolbyVisionTest.kt
@@ -19,6 +19,8 @@ import org.prairieserver.prairie.model.playback.DELIVERY_CLASS_HLS
import org.prairieserver.prairie.model.playback.DELIVERY_CLASS_ORIGINAL_HTTP
import org.prairieserver.prairie.model.playback.DELIVERY_CLASS_PROGRESSIVE
import org.prairieserver.prairie.model.playback.NATIVE_HLS_PLAYBACK_V1_FEATURE
+import org.prairieserver.prairie.model.playback.HLS_SUBTITLE_RENDITIONS_V1_FEATURE
+import org.prairieserver.prairie.model.playback.playbackClientFeaturesV3
import org.prairieserver.prairie.model.playback.CLIENT_SELECTED_AUDIO_TRACK_V1_CLAIM
import org.prairieserver.prairie.model.playback.CLIENT_DV8_BASE_LAYER_FALLBACK_V1_CLAIM
import kotlin.test.Test
@@ -58,6 +60,10 @@ class PlaybackCapabilityDetectorDolbyVisionTest {
NATIVE_HLS_PLAYBACK_V1_FEATURE in playbackContext.deliveries.getValue(DELIVERY_CLASS_PROGRESSIVE).features,
"$formFactor must not apply the HLS sample-entry contract to progressive delivery",
)
+ assertTrue(
+ HLS_SUBTITLE_RENDITIONS_V1_FEATURE in playbackClientFeaturesV3(playbackContext),
+ "$formFactor selects HLS subtitle renditions in Media3",
+ )
}
}
diff --git a/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/PlaybackNetworkSnapshotTest.kt b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/PlaybackNetworkSnapshotTest.kt
new file mode 100644
index 0000000000..93c625330a
--- /dev/null
+++ b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/PlaybackNetworkSnapshotTest.kt
@@ -0,0 +1,70 @@
+package org.prairieserver.prairie.common.player
+
+import android.net.NetworkCapabilities
+import android.net.NetworkCapabilities.TRANSPORT_CELLULAR
+import android.net.NetworkCapabilities.TRANSPORT_VPN
+import android.net.NetworkCapabilities.TRANSPORT_WIFI
+import org.junit.runner.RunWith
+import org.robolectric.RobolectricTestRunner
+import org.robolectric.Shadows.shadowOf
+import org.robolectric.annotation.Config
+import org.robolectric.shadows.ShadowNetworkCapabilities
+import kotlin.test.Test
+import kotlin.test.assertFalse
+import kotlin.test.assertTrue
+
+/** Which networks get the phone's cellular quality pair, from Android's own capabilities. */
+@RunWith(RobolectricTestRunner::class)
+@Config(sdk = [35])
+class PlaybackNetworkSnapshotTest {
+
+ @Test
+ fun `mobile data uses the cellular pair`() {
+ assertTrue(cellularPair(caps(TRANSPORT_CELLULAR, metered = true)))
+ // A carrier that marks its plan unmetered is still mobile data.
+ assertTrue(cellularPair(caps(TRANSPORT_CELLULAR, metered = false)))
+ }
+
+ @Test
+ fun `metered Wi-Fi such as a phone hotspot uses the cellular pair`() {
+ assertTrue(cellularPair(caps(TRANSPORT_WIFI, metered = true)))
+ }
+
+ @Test
+ fun `unmetered Wi-Fi uses the main pair`() {
+ assertFalse(cellularPair(caps(TRANSPORT_WIFI, metered = false)))
+ }
+
+ @Test
+ fun `a VPN over Wi-Fi uses the main pair even though Android marks it metered`() {
+ assertFalse(cellularPair(caps(TRANSPORT_VPN, TRANSPORT_WIFI, metered = true)))
+ assertFalse(cellularPair(caps(TRANSPORT_VPN, TRANSPORT_WIFI, metered = false)))
+ }
+
+ @Test
+ fun `a VPN over mobile data uses the cellular pair`() {
+ assertTrue(cellularPair(caps(TRANSPORT_VPN, TRANSPORT_CELLULAR, metered = true)))
+ assertTrue(cellularPair(caps(TRANSPORT_VPN, TRANSPORT_CELLULAR, metered = false)))
+ }
+
+ @Test
+ fun `no connection uses the main pair`() {
+ assertFalse(cellularPair(null))
+ }
+
+ private fun cellularPair(caps: NetworkCapabilities?): Boolean =
+ playbackNetworkSnapshot(caps).usesCellularQuality
+
+ /** A VPN reports its own transport plus those of the networks it runs over. */
+ private fun caps(vararg transports: Int, metered: Boolean): NetworkCapabilities =
+ ShadowNetworkCapabilities.newInstance().also { caps ->
+ val shadow = shadowOf(caps)
+ transports.forEach { shadow.addTransportType(it) }
+ if (TRANSPORT_VPN in transports) shadow.removeCapability(NetworkCapabilities.NET_CAPABILITY_NOT_VPN)
+ if (metered) {
+ shadow.removeCapability(NetworkCapabilities.NET_CAPABILITY_NOT_METERED)
+ } else {
+ shadow.addCapability(NetworkCapabilities.NET_CAPABILITY_NOT_METERED)
+ }
+ }
+}
diff --git a/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/PlaybackSessionLifecycleTest.kt b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/PlaybackSessionLifecycleTest.kt
index c67960a3a0..ac2a755aa9 100644
--- a/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/PlaybackSessionLifecycleTest.kt
+++ b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/PlaybackSessionLifecycleTest.kt
@@ -88,6 +88,37 @@ class PlaybackSessionLifecycleTest {
assertEquals(2, manager.progressCallCount)
}
+ @Test fun `a session the server ended is handed to the owner to leave, not renewed`() = runTest {
+ val ended = ApiResult.Error(410, "playback_session_ended", "An administrator ended this playback session")
+ for (sequenced in listOf(true, false)) {
+ val manager = object : FakeSessionManager() {
+ override fun isSequenced(sessionId: String) = sequenced
+ }.apply { progressDefault = ended }
+ val lifecycle = newLifecycle(manager)
+ val endedIds = mutableListOf()
+ val renewals = mutableListOf()
+ val collectors = listOf(
+ backgroundScope.launch { lifecycle.sessionEndedEvents.collect { endedIds += it } },
+ backgroundScope.launch { lifecycle.missingSessionEvents.collect { renewals += it.staleSessionId } },
+ )
+ lifecycle.adoptActiveSession(defaultStartParams(), makeSession("ended-$sequenced"))
+ lifecycle.reportOwnedPosition(10.0, 100.0, false)
+ advanceTimeBy(PlaybackSessionLifecycle.PROGRESS_REPORT_INTERVAL_MS + 100)
+ assertEquals(listOf("ended-$sequenced"), endedIds, "sequenced=$sequenced")
+ assertTrue(renewals.isEmpty(), "sequenced=$sequenced")
+ assertNull(lifecycle.notice.value)
+ collectors.forEach { it.cancel() }
+ lifecycle.stop()
+ }
+ }
+
+ @Test fun `only a 410 playback_session_ended counts as a session the server ended`() {
+ assertTrue(ApiResult.Error(410, "playback_session_ended", "").isPlaybackSessionEnded())
+ assertFalse(ApiResult.Error(404, "not_found", "").isPlaybackSessionEnded())
+ assertFalse(ApiResult.Error(410, "gone", "").isPlaybackSessionEnded())
+ assertFalse(ApiResult.Success(Unit).isPlaybackSessionEnded())
+ }
+
@Test fun `sequenced progress warns only after consecutive failures and clears on success`() = runTest {
val failure = ApiResult.Error(401, "invalid_token", "expired")
val manager = object : FakeSessionManager() {
diff --git a/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/PlaybackSessionManagerSeekReanchorTest.kt b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/PlaybackSessionManagerSeekReanchorTest.kt
index d53c4788d9..6cf65ce8f8 100644
--- a/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/PlaybackSessionManagerSeekReanchorTest.kt
+++ b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/PlaybackSessionManagerSeekReanchorTest.kt
@@ -19,6 +19,7 @@ import kotlinx.coroutines.yield
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonElement
+import kotlinx.serialization.json.JsonNull
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.double
@@ -180,8 +181,14 @@ class PlaybackSessionManagerSeekReanchorTest {
"7",
request["client_playback_context"]!!.jsonObject["output"]!!.jsonObject.string("output_context_id"),
)
- assertFalse(request["metered"]!!.jsonPrimitive.content.toBoolean())
- assertEquals(50_000, request["bandwidth_estimate_kbps"]!!.jsonPrimitive.int)
+ // No request carries an estimate, even on a measured link: Media3's
+ // figure can understate it and make Auto convert. `metered` goes out
+ // false even on a metered link: older servers require it on /api/v2
+ // and drop Auto to their metered limit on a true one.
+ for (body in harness.startBodies + request) {
+ assertFalse(body["metered"]!!.jsonPrimitive.content.toBoolean())
+ assertNull(body["bandwidth_estimate_kbps"])
+ }
assertEquals("file:42:audio:1", request["selected_tracks"]!!.jsonObject["audio"]!!.jsonObject.string("id"))
assertEquals(listOf("hevc"), request["client_capabilities"]!!.jsonObject["codecs_video"]!!.jsonArray.map { it.jsonPrimitive.content })
// Keys are server-owned: a local mutation records itself in
@@ -567,6 +574,91 @@ class PlaybackSessionManagerSeekReanchorTest {
assertEquals("server-owned-subtitle-id", selectedTracks["subtitle"]!!.jsonObject.string("id"))
}
+ @Test
+ fun `a network quality pair replaces the quality and cap for this and later replans`() = runTest {
+ val initial = plan()
+ val harness = Harness(response(initial)) { index, _ ->
+ success(response(initial.copy(planId = "plan-${index + 2}", planAttemptKey = "v3:00000000000000b$index")))
+ }
+ harness.manager.start()
+ assertEquals(PlaybackQualityPair("original", null), harness.manager.activeVideoQualityPair())
+
+ val cellular = PlaybackQualityPair("720p", 2_000)
+ val moved = harness.manager.replanActiveVideoSession(
+ classification = "quality_changed",
+ positionSeconds = 30.0,
+ audioTrackIndex = 1,
+ subtitleTrackIndex = null,
+ qualityPair = cellular,
+ )
+
+ assertIs(assertIs>(moved).data)
+ val request = harness.replanBodies.single()
+ assertEquals("quality_change", request.string("operation"))
+ assertEquals("720p", request.string("quality_preference"))
+ assertEquals(2_000, request["bandwidth_cap_kbps"]!!.jsonPrimitive.int)
+ assertEquals(cellular, harness.manager.activeVideoQualityPair())
+
+ // A later replan that names no pair keeps the new one.
+ harness.manager.replanActiveVideoSession(
+ classification = "audio_track_changed",
+ positionSeconds = 40.0,
+ audioTrackIndex = 1,
+ subtitleTrackIndex = null,
+ )
+ assertEquals("720p", harness.replanBodies[1].string("quality_preference"))
+ assertEquals(2_000, harness.replanBodies[1]["bandwidth_cap_kbps"]!!.jsonPrimitive.int)
+
+ // An uncapped pair lifts the cap.
+ harness.manager.replanActiveVideoSession(
+ classification = "quality_changed",
+ positionSeconds = 50.0,
+ audioTrackIndex = 1,
+ subtitleTrackIndex = null,
+ qualityPair = PlaybackQualityPair("auto", null),
+ )
+ assertEquals("auto", harness.replanBodies[2].string("quality_preference"))
+ assertTrue(harness.replanBodies[2]["bandwidth_cap_kbps"].let { it == null || it is JsonNull })
+ assertEquals(PlaybackQualityPair("auto", null), harness.manager.activeVideoQualityPair())
+ }
+
+ @Test
+ fun `an Auto picked in the player is staged with the cap it is given`() = runTest {
+ val initial = plan()
+ val harness = Harness(response(initial)) { index, _ ->
+ success(response(initial.copy(planId = "plan-${index + 2}", planAttemptKey = "v3:00000000000000c$index")))
+ }
+ // The session started under a 40 Mbps cap.
+ assertIs>(
+ harness.manager.startVideoSessionV3(
+ fileId = 42,
+ profileId = "profile-1",
+ capabilities = ClientCodecCapabilities(),
+ clientPlaybackContext = ClientPlaybackContext(formFactor = "mobile", appVersion = "test"),
+ audioTrackIndex = 1,
+ subtitleTrackIndex = null,
+ qualityPreference = "1080p",
+ startPosition = 0.0,
+ maxBitrateKbps = 40_000,
+ ),
+ )
+
+ val staged = harness.manager.stageActiveVideoSessionReplan(
+ classification = "quality_changed",
+ positionSeconds = 30.0,
+ audioTrackIndex = 1,
+ subtitleTrackIndex = null,
+ qualityPreference = "auto",
+ qualityPair = PlaybackQualityPair("auto", 2_000),
+ )
+
+ assertIs>(staged)
+ val request = harness.replanBodies.single()
+ assertEquals("quality_change", request.string("operation"))
+ assertEquals("auto", request.string("quality_preference"))
+ assertEquals(2_000, request["bandwidth_cap_kbps"]!!.jsonPrimitive.int)
+ }
+
private class Harness(
startResponse: PlaybackDecisionResponseV3,
networkEvidenceProvider: PlaybackNetworkEvidenceProvider = PlaybackNetworkEvidenceProvider.None,
diff --git a/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/PlaybackSessionManagerStagedReplanTest.kt b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/PlaybackSessionManagerStagedReplanTest.kt
index 172a0caa6d..ee8f33ab2f 100644
--- a/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/PlaybackSessionManagerStagedReplanTest.kt
+++ b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/PlaybackSessionManagerStagedReplanTest.kt
@@ -57,10 +57,12 @@ import org.prairieserver.prairie.model.playback.PlaybackStreamProtocol
import org.prairieserver.prairie.model.playback.PlaybackStreamV3
import org.prairieserver.prairie.model.playback.PlaybackSubtitleArtifactV3
import org.prairieserver.prairie.model.playback.PlaybackSubtitleDecisionV3
+import org.prairieserver.prairie.model.playback.PlaybackSubtitleHlsRenditionV3
import org.prairieserver.prairie.model.playback.PlaybackSubtitleInventoryItemV3
import org.prairieserver.prairie.model.playback.PlaybackSubtitleModeV3
import org.prairieserver.prairie.model.playback.PlaybackTerminalV3
import org.prairieserver.prairie.model.playback.PlaybackTrackIdentityV3
+import org.prairieserver.prairie.model.playback.SUBTITLE_HLS_RENDITION_FAILED_CLASSIFICATION
import org.prairieserver.prairie.model.playback.SelectedPlaybackTracksV3
import org.prairieserver.prairie.model.playback.SubtitleFidelityPreference
import org.prairieserver.prairie.network.ApiResult
@@ -757,6 +759,110 @@ class PlaybackSessionManagerStagedReplanTest {
assertEquals(emptyList(), harness.stoppedSessions)
}
+ @Test
+ fun `hls rendition candidate commits without sidecar artifact`() = runTest {
+ val harness = Harness(
+ replanResponse = { _, _ -> response(renditionPlan("s2")) },
+ )
+ harness.start()
+
+ val staged = harness.stageSidecar()
+ val committed = assertIs>(
+ harness.manager.commitStagedVideoReplan(staged),
+ ).data
+
+ val selected = committed.session.subtitleUrls!!.single { it.index == 4 }
+ assertEquals("subs", selected.hlsRendition?.groupId)
+ assertEquals("", selected.url)
+ assertEquals(emptyList(), harness.stoppedSessions)
+ }
+
+ @Test
+ fun `off candidate that still carries a rendition is rejected`() = runTest {
+ val harness = Harness(
+ replanResponse = { _, _ ->
+ response(
+ renditionPlan("s2").let { plan ->
+ plan.copy(
+ selectedTracks = plan.selectedTracks.copy(subtitle = null),
+ subtitle = plan.subtitle.copy(mode = PlaybackSubtitleModeV3.OFF, trackId = null),
+ )
+ },
+ )
+ },
+ )
+ harness.start()
+
+ val staged = harness.manager.stageActiveVideoSessionReplan(
+ classification = "subtitle_track_changed",
+ positionSeconds = 42.0,
+ audioTrackIndex = 0,
+ subtitleTrackIndex = null,
+ )
+
+ assertIs(staged)
+ assertEquals("s1", harness.manager.activeSessionIdForTest())
+ }
+
+ @Test
+ fun `rendition failure recovers through failure recovery for the sidecar plan`() = runTest {
+ val harness = Harness(
+ replanResponse = { index, _ -> response(if (index == 0) renditionPlan("s2") else replanPlan("s3")) },
+ )
+ harness.start()
+ assertIs>(
+ harness.manager.commitStagedVideoReplan(harness.stageSidecar()),
+ )
+
+ val recovered = assertIs>(
+ harness.manager.replanActiveVideoSession(
+ classification = SUBTITLE_HLS_RENDITION_FAILED_CLASSIFICATION,
+ positionSeconds = 43.0,
+ audioTrackIndex = 0,
+ subtitleTrackIndex = 4,
+ ),
+ ).data
+
+ val ready = assertIs(recovered)
+ assertEquals(sidecarArtifact("s1", 4), ready.plan.subtitle.artifact)
+ val request = harness.replanBodies[1]
+ assertEquals("failure_recovery", request["operation"]!!.jsonPrimitive.content)
+ assertEquals(
+ SUBTITLE_HLS_RENDITION_FAILED_CLASSIFICATION,
+ request["failure"]!!.jsonObject["classification"]!!.jsonPrimitive.content,
+ )
+ assertTrue(
+ "v3:test:rendition-s2" in request["attempted_plan_keys"]!!.jsonArray.map { it.jsonPrimitive.content },
+ )
+ assertEquals(4, request["selected_tracks"]!!.jsonObject["subtitle"]!!.jsonObject["index"]!!.jsonPrimitive.int)
+ assertEquals(emptyList(), harness.stoppedSessions)
+ }
+
+ @Test
+ fun `rejected rendition recovery keeps the healthy session`() = runTest {
+ val harness = Harness(
+ replanResponse = { index, _ ->
+ if (index == 0) response(renditionPlan("s2")) else response(renditionPlan("s2"))
+ },
+ )
+ harness.start()
+ assertIs>(
+ harness.manager.commitStagedVideoReplan(harness.stageSidecar()),
+ )
+
+ // The server repeating the failed plan is a loop. A subtitle retry
+ // reports it as an error rather than a terminal that ends playback.
+ val result = harness.manager.replanActiveVideoSession(
+ classification = SUBTITLE_HLS_RENDITION_FAILED_CLASSIFICATION,
+ positionSeconds = 43.0,
+ audioTrackIndex = 0,
+ subtitleTrackIndex = 4,
+ )
+
+ assertIs(result)
+ assertEquals("s1", harness.manager.activeSessionIdForTest())
+ }
+
@Test
fun `sidecar candidate without artifact is rejected`() = runTest {
val harness = Harness(
@@ -1782,6 +1888,20 @@ class PlaybackSessionManagerStagedReplanTest {
planId = "plan-$planId", planAttemptKey = "v3:test:$planId",
)
+ fun renditionPlan(planId: String, sessionId: String = "s1") = replanPlan(planId, sessionId).let { plan ->
+ plan.copy(
+ planId = "plan-rendition-$planId",
+ planAttemptKey = "v3:test:rendition-$planId",
+ delivery = PlaybackDelivery.SERVER_TRANSCODE_HLS,
+ stream = plan.stream.copy(url = "/api/v2/playback/transcode/$sessionId/master.m3u8"),
+ subtitle = plan.subtitle.copy(
+ mode = PlaybackSubtitleModeV3.RENDER,
+ artifact = null,
+ hlsRendition = PlaybackSubtitleHlsRenditionV3("subs", "Subtitles", "vtt", "text/vtt"),
+ ),
+ )
+ }
+
fun basePlan(
sessionId: String = "s1",
fileId: Int = 42,
diff --git a/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/cast/CastPlaybackPreparerTest.kt b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/cast/CastPlaybackPreparerTest.kt
index c5e788b715..5b65930766 100644
--- a/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/cast/CastPlaybackPreparerTest.kt
+++ b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/cast/CastPlaybackPreparerTest.kt
@@ -8,6 +8,7 @@ import org.prairieserver.prairie.model.playback.PlaybackStreamV3
import org.prairieserver.prairie.model.playback.PlaybackSubtitleDecisionV3
import org.prairieserver.prairie.model.playback.PlaybackSubtitleInventoryItemV3
import org.prairieserver.prairie.model.playback.PlaybackTimelineV3
+import org.prairieserver.prairie.model.playback.HLS_SUBTITLE_RENDITIONS_V1_FEATURE
import org.prairieserver.prairie.model.playback.playbackClientFeaturesV3
import kotlin.test.Test
import kotlin.test.assertEquals
@@ -70,6 +71,19 @@ class CastPlaybackPreparerTest {
)
}
+ @Test
+ fun castContextKeepsSidecarsInsteadOfHlsSubtitleRenditions() {
+ assertFalse(
+ HLS_SUBTITLE_RENDITIONS_V1_FEATURE in
+ playbackClientFeaturesV3(
+ chromecastPlaybackContext(
+ appVersion = "test",
+ buildIdentity = PrairieClientBuildIdentity(buildNumber = "5", channel = "release"),
+ ),
+ ),
+ )
+ }
+
@Test
fun castUsesPlayerLocalStartInsteadOfSourceTimelinePosition() {
val plan = plan(
diff --git a/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/settings/AndroidPlayerSettingsStoreTest.kt b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/settings/AndroidPlayerSettingsStoreTest.kt
index 79f2468432..615c60d8d8 100644
--- a/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/settings/AndroidPlayerSettingsStoreTest.kt
+++ b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/settings/AndroidPlayerSettingsStoreTest.kt
@@ -21,6 +21,7 @@ import org.prairieserver.prairie.network.ApiResult
import org.prairieserver.prairie.network.api.SettingsApi
import org.prairieserver.prairie.repository.SettingsRepository
import io.ktor.client.HttpClient
+import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flowOf
@@ -835,17 +836,17 @@ class AndroidPlayerSettingsStoreTest {
@Test
fun `using the profile setting clears every key the control writes`() = runTest {
val store = newStore(repository = SettingsRepository(FakeSettingsApi()))
- store.setQuality("1080p", 8000)
+ store.setCellularQuality("1080p", 8000)
store.setIntroSkipMode(IntroSkipMode.NEVER)
- store.resetDeviceSetting(PlaybackSettingsKeys.PreferredQuality)
+ store.resetDeviceSetting(PlaybackSettingsKeys.CellularPreferredQuality)
store.resetDeviceSetting(PlaybackSettingsKeys.IntroSkipMode)
val deleted = fakeFlusher.calls.filter { it.isDelete }.map { it.key }.toSet()
assertEquals(
setOf(
- PlaybackSettingsKeys.PreferredQuality,
- PlaybackSettingsKeys.MaxBitrateKbps,
+ PlaybackSettingsKeys.CellularPreferredQuality,
+ PlaybackSettingsKeys.CellularMaxBitrateKbps,
PlaybackSettingsKeys.IntroSkipMode,
PlaybackSettingsKeys.AutoSkipIntro,
),
@@ -854,6 +855,21 @@ class AndroidPlayerSettingsStoreTest {
assertEquals(emptySet(), store.deviceOverrideKeysFlow.first())
}
+ @Test
+ fun `the resolution and bitrate pickers go back to the profile separately`() = runTest {
+ val store = newStore(repository = SettingsRepository(FakeSettingsApi()))
+ store.setPreferredQuality("1080p")
+ store.setMaxBitrateKbps(8000)
+
+ store.resetDeviceSetting(PlaybackSettingsKeys.PreferredQuality)
+
+ assertEquals(
+ listOf(PlaybackSettingsKeys.PreferredQuality),
+ fakeFlusher.calls.filter { it.isDelete }.map { it.key },
+ )
+ assertEquals(setOf(PlaybackSettingsKeys.MaxBitrateKbps), store.deviceOverrideKeysFlow.first())
+ }
+
@Test
fun `no audio language preference clears the device value instead of storing null`() = runTest {
val store = newStore(repository = SettingsRepository(FakeSettingsApi()))
@@ -1033,22 +1049,31 @@ class AndroidPlayerSettingsStoreTest {
}
@Test
- fun `setQuality writes both axes and flushes them together`() = runTest {
+ fun `each quality picker writes and flushes only its own axis`() = runTest {
val store = newStore()
- store.setQuality("1080p", 10000)
+ store.setPreferredQuality("1080p")
+ assertEquals(listOf(PlaybackSettingsKeys.PreferredQuality), fakeFlusher.calls.map { it.key })
+ assertEquals(setOf(PlaybackSettingsKeys.PreferredQuality), store.deviceOverrideKeysFlow.first())
+ store.setMaxBitrateKbps(40_000)
+ assertEquals(
+ listOf(PlaybackSettingsKeys.PreferredQuality, PlaybackSettingsKeys.MaxBitrateKbps),
+ fakeFlusher.calls.map { it.key },
+ )
assertEquals("1080p", store.preferredQualityFlow.first())
- assertEquals(10000, store.maxBitrateKbpsFlow.first())
- val flushed = fakeFlusher.calls.filterNot { it.isDelete }.associate { it.key to it.value }
- assertEquals("1080p", flushed[PlaybackSettingsKeys.PreferredQuality])
- assertEquals("10000", flushed[PlaybackSettingsKeys.MaxBitrateKbps])
+ assertEquals(40_000, store.maxBitrateKbpsFlow.first())
+ assertEquals("40000", fakeFlusher.calls.last().value)
+ assertEquals(
+ setOf(PlaybackSettingsKeys.PreferredQuality, PlaybackSettingsKeys.MaxBitrateKbps),
+ store.deviceOverrideKeysFlow.first(),
+ )
}
@Test
- fun `an uncapped preset stores no bitrate`() = runTest {
+ fun `no bitrate limit stores no cap`() = runTest {
val store = newStore()
- store.setQuality("1080p", 6000)
- store.setQuality("original", null)
+ store.setMaxBitrateKbps(6000)
+ store.setMaxBitrateKbps(null)
// null is uncapped, which the store spells as 0 โ outside the
// contract's range, so it can never read back as a real cap.
@@ -1059,6 +1084,259 @@ class AndroidPlayerSettingsStoreTest {
)
}
+ @Test
+ fun `a bitrate no picker offers reads back as stored`() = runTest {
+ // Set through the API or another client: kept, not snapped to a choice.
+ val store = newStore(
+ repository = SettingsRepository(
+ FakeSettingsApi(effective = mapOf(stored(PlaybackSettingsKeys.MaxBitrateKbps, JsonPrimitive(8_500)))),
+ ),
+ )
+ store.refreshFromServer()
+
+ assertEquals(8_500, store.maxBitrateKbpsFlow.first())
+ val choices = org.prairieserver.prairie.model.settings.QualityChoices.bitrateChoices(store.maxBitrateKbpsFlow.first())
+ assertEquals(1, choices.count { it.value == 8_500 })
+ }
+
+ @Test
+ fun `cellular quality defaults to the 720p preset`() = runTest {
+ val store = newStore()
+
+ assertEquals("720p", store.cellularPreferredQualityFlow.first())
+ assertEquals(2_000, store.cellularMaxBitrateKbpsFlow.first())
+ }
+
+ @Test
+ fun `a cellular preset writes both cellular axes and leaves the main pair alone`() = runTest {
+ val store = newStore()
+ store.setPreferredQuality("2160p")
+ store.setCellularQuality("1080p", 3000)
+
+ assertEquals("1080p", store.cellularPreferredQualityFlow.first())
+ assertEquals(3000, store.cellularMaxBitrateKbpsFlow.first())
+ assertEquals("2160p", store.preferredQualityFlow.first())
+ assertEquals(null, store.maxBitrateKbpsFlow.first())
+ val flushed = fakeFlusher.calls.filterNot { it.isDelete }.associate { it.key to it.value }
+ assertEquals("1080p", flushed[PlaybackSettingsKeys.CellularPreferredQuality])
+ assertEquals("3000", flushed[PlaybackSettingsKeys.CellularMaxBitrateKbps])
+
+ // The uncapped presets (Auto, 4K) store no cap rather than the default.
+ store.setCellularQuality("auto", null)
+ assertEquals("auto", store.cellularPreferredQualityFlow.first())
+ assertEquals(null, store.cellularMaxBitrateKbpsFlow.first())
+ assertEquals("0", fakeFlusher.calls.last { it.key == PlaybackSettingsKeys.CellularMaxBitrateKbps }.value)
+ }
+
+ @Test
+ fun `cellular quality is read from a revision 20 server and left out for an older one`() = runTest {
+ var revision = capabilitiesAt(19)
+ val contractRevision = SettingsContractRevision({ revision }, { serverUrl })
+ val api = FakeSettingsApi(
+ effective = mapOf(
+ stored(PlaybackSettingsKeys.CellularPreferredQuality, JsonPrimitive("480p")),
+ stored(PlaybackSettingsKeys.CellularMaxBitrateKbps, JsonPrimitive(1_500)),
+ ),
+ )
+ val store = AndroidPlayerSettingsStore(
+ context = mockContextStub(),
+ legacyCache = fakeLegacyCache,
+ getActiveProfileId = { activeProfileId },
+ getServerUrl = { serverUrl },
+ serverSettingsFlusher = fakeFlusher,
+ settingsRepository = SettingsRepository(api),
+ getDeviceId = { null },
+ contractRevision = contractRevision,
+ dataStoreFactory = { id ->
+ PreferenceDataStoreFactory.create(produceFile = { File(tempFolder.root, "ds_cell_$id.preferences_pb") })
+ },
+ )
+ store.setCellularQuality("1080p", 6000)
+
+ // An older server would refuse the whole batch over a key it lacks,
+ // so the pair is not asked for, and the local choice still applies.
+ store.refreshFromServer()
+ assertTrue(api.requestedKeys.isNotEmpty())
+ assertTrue(api.requestedKeys.none { it in org.prairieserver.prairie.model.settings.CellularQuality.KEYS })
+ assertEquals("1080p", store.cellularPreferredQualityFlow.first())
+ assertEquals(6000, store.cellularMaxBitrateKbpsFlow.first())
+
+ // "Use profile setting" has no profile value to reach there, so the
+ // contract default applies.
+ store.resetDeviceSetting(PlaybackSettingsKeys.CellularPreferredQuality)
+ assertEquals("720p", store.cellularPreferredQualityFlow.first())
+ assertEquals(2_000, store.cellularMaxBitrateKbpsFlow.first())
+
+ revision = capabilitiesAt(20)
+ store.refreshFromServer()
+ assertTrue(api.requestedKeys.containsAll(org.prairieserver.prairie.model.settings.CellularQuality.KEYS))
+ assertEquals("480p", store.cellularPreferredQualityFlow.first())
+ assertEquals(1_500, store.cellularMaxBitrateKbpsFlow.first())
+ }
+
+ @Test
+ fun `using the profile setting for cellular quality before the revision is known still resets it`() = runTest {
+ fun storeAt(revision: () -> ApiResult, name: String) = AndroidPlayerSettingsStore(
+ context = mockContextStub(),
+ legacyCache = fakeLegacyCache,
+ getActiveProfileId = { activeProfileId },
+ getServerUrl = { serverUrl },
+ serverSettingsFlusher = fakeFlusher,
+ settingsRepository = SettingsRepository(FakeSettingsApi()),
+ getDeviceId = { null },
+ contractRevision = SettingsContractRevision({ revision() }, { serverUrl }),
+ dataStoreFactory = { id ->
+ PreferenceDataStoreFactory.create(produceFile = { File(tempFolder.root, "ds_cell_${name}_$id.preferences_pb") })
+ },
+ )
+
+ // Nothing has read the revision yet; the server turns out to be 19,
+ // which drops the delete and never sends the key back.
+ val older = storeAt({ capabilitiesAt(19) }, "older")
+ older.setCellularQuality("1080p", 6_000)
+ older.resetDeviceSetting(PlaybackSettingsKeys.CellularPreferredQuality)
+ older.refreshFromServer()
+ assertEquals("720p", older.cellularPreferredQualityFlow.first())
+ assertEquals(2_000, older.cellularMaxBitrateKbpsFlow.first())
+
+ // Offline, the default applies until a server that stores the pair
+ // answers with the profile's value.
+ val offline = storeAt({ ApiResult.NetworkError(IllegalStateException("offline")) }, "offline")
+ offline.setCellularQuality("1080p", 6_000)
+ offline.resetDeviceSetting(PlaybackSettingsKeys.CellularPreferredQuality)
+ assertEquals("720p", offline.cellularPreferredQualityFlow.first())
+ assertEquals(2_000, offline.cellularMaxBitrateKbpsFlow.first())
+ }
+
+ @Test
+ fun `a cellular choice made while a reset waits on the server is kept`() = runTest {
+ val probing = CompletableDeferred()
+ val answer = CompletableDeferred()
+ val store = AndroidPlayerSettingsStore(
+ context = mockContextStub(),
+ legacyCache = fakeLegacyCache,
+ getActiveProfileId = { activeProfileId },
+ getServerUrl = { serverUrl },
+ serverSettingsFlusher = fakeFlusher,
+ settingsRepository = SettingsRepository(FakeSettingsApi()),
+ getDeviceId = { null },
+ contractRevision = SettingsContractRevision(
+ {
+ probing.complete(Unit)
+ answer.await()
+ capabilitiesAt(19)
+ },
+ { serverUrl },
+ ),
+ dataStoreFactory = { id ->
+ PreferenceDataStoreFactory.create(produceFile = { File(tempFolder.root, "ds_cell_race_$id.preferences_pb") })
+ },
+ )
+ store.setCellularQuality("1080p", 6_000)
+
+ val reset = launch { store.resetDeviceSetting(PlaybackSettingsKeys.CellularPreferredQuality) }
+ probing.await()
+ // The user picks again while the server has not answered.
+ store.setCellularQuality("480p", 1_500)
+ answer.complete(Unit)
+ reset.join()
+
+ assertEquals("480p", store.cellularPreferredQualityFlow.first())
+ assertEquals(1_500, store.cellularMaxBitrateKbpsFlow.first())
+ assertTrue(PlaybackSettingsKeys.CellularPreferredQuality in store.deviceOverrideKeysFlow.first())
+ val last = fakeFlusher.calls.last { it.key == PlaybackSettingsKeys.CellularPreferredQuality }
+ assertFalse(last.isDelete, "the newer choice is the last write for the key")
+ assertEquals("480p", last.value)
+ }
+
+ @Test
+ fun `a cellular choice made against an older server reaches it once it upgrades`() = runTest {
+ var revision = capabilitiesAt(19)
+ val contractRevision = SettingsContractRevision({ revision }, { serverUrl })
+ // Upgraded, the server has nothing stored for this device yet.
+ val api = FakeSettingsApi(
+ effective = mapOf(
+ defaulted(PlaybackSettingsKeys.CellularPreferredQuality, JsonPrimitive("720p")),
+ defaulted(PlaybackSettingsKeys.CellularMaxBitrateKbps, JsonPrimitive(2_000)),
+ ),
+ )
+ val store = AndroidPlayerSettingsStore(
+ context = mockContextStub(),
+ legacyCache = fakeLegacyCache,
+ getActiveProfileId = { activeProfileId },
+ getServerUrl = { serverUrl },
+ serverSettingsFlusher = fakeFlusher,
+ settingsRepository = SettingsRepository(api),
+ getDeviceId = { null },
+ contractRevision = contractRevision,
+ dataStoreFactory = { id ->
+ PreferenceDataStoreFactory.create(produceFile = { File(tempFolder.root, "ds_cell_upgrade_$id.preferences_pb") })
+ },
+ )
+ store.refreshFromServer()
+ // The flusher drops these for the revision 19 server.
+ store.setCellularQuality("480p", 1_500)
+ fakeFlusher.calls.clear()
+
+ revision = capabilitiesAt(20)
+ store.refreshFromServer()
+
+ assertEquals("480p", store.cellularPreferredQualityFlow.first())
+ assertEquals(1_500, store.cellularMaxBitrateKbpsFlow.first())
+ assertEquals(
+ mapOf(
+ PlaybackSettingsKeys.CellularPreferredQuality to "480p",
+ PlaybackSettingsKeys.CellularMaxBitrateKbps to "1500",
+ ),
+ fakeFlusher.calls.filterNot { it.isDelete }.associate { it.key to it.value },
+ "sent again now that the server stores them",
+ )
+ assertTrue(PlaybackSettingsKeys.CellularPreferredQuality in store.deviceOverrideKeysFlow.first())
+
+ // Once the server shows them stored for this device, its answer applies
+ // again, including a later clear made elsewhere.
+ api.effective = mapOf(
+ stored(PlaybackSettingsKeys.CellularPreferredQuality, JsonPrimitive("480p")),
+ stored(PlaybackSettingsKeys.CellularMaxBitrateKbps, JsonPrimitive(1_500)),
+ )
+ store.refreshFromServer()
+ api.effective = mapOf(
+ stored(PlaybackSettingsKeys.CellularPreferredQuality, JsonPrimitive("1080p"), scope = SettingScope.PROFILE.wire),
+ stored(PlaybackSettingsKeys.CellularMaxBitrateKbps, JsonPrimitive(6_000), scope = SettingScope.PROFILE.wire),
+ )
+ store.refreshFromServer()
+ assertEquals("1080p", store.cellularPreferredQualityFlow.first())
+ assertEquals(6_000, store.cellularMaxBitrateKbpsFlow.first())
+ assertFalse(PlaybackSettingsKeys.CellularPreferredQuality in store.deviceOverrideKeysFlow.first())
+ }
+
+ @Test
+ fun `cellular quality is left out of the batch while the server revision is unknown`() = runTest {
+ val contractRevision = SettingsContractRevision(
+ { ApiResult.NetworkError(IllegalStateException("offline")) },
+ { serverUrl },
+ )
+ val api = FakeSettingsApi()
+ val store = AndroidPlayerSettingsStore(
+ context = mockContextStub(),
+ legacyCache = fakeLegacyCache,
+ getActiveProfileId = { activeProfileId },
+ getServerUrl = { serverUrl },
+ serverSettingsFlusher = fakeFlusher,
+ settingsRepository = SettingsRepository(api),
+ getDeviceId = { null },
+ contractRevision = contractRevision,
+ dataStoreFactory = { id ->
+ PreferenceDataStoreFactory.create(produceFile = { File(tempFolder.root, "ds_cell_unknown_$id.preferences_pb") })
+ },
+ )
+
+ store.refreshFromServer()
+
+ assertTrue(api.requestedKeys.contains(PlaybackSettingsKeys.PreferredQuality))
+ assertTrue(api.requestedKeys.none { it in org.prairieserver.prairie.model.settings.CellularQuality.KEYS })
+ }
+
@Test
fun `a legacy compound quality normalizes on read and on write`() = runTest {
val store = newStore()
@@ -1084,7 +1362,8 @@ class AndroidPlayerSettingsStoreTest {
),
)
val store = newStore(repository = repo)
- store.setQuality("1080p", 10000)
+ store.setPreferredQuality("1080p")
+ store.setMaxBitrateKbps(10000)
store.refreshFromServer()
diff --git a/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/settings/ServerDrivenConfigRefresherTest.kt b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/settings/ServerDrivenConfigRefresherTest.kt
index 21d403a957..b055a8c337 100644
--- a/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/settings/ServerDrivenConfigRefresherTest.kt
+++ b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/settings/ServerDrivenConfigRefresherTest.kt
@@ -251,7 +251,6 @@ private class FakePlayerSettingsStore : PlayerSettingsStore {
override suspend fun setResumeRewindSeconds(value: Int) = Unit
override suspend fun setPassOutThreshold(value: Int) = Unit
override suspend fun setPreferredQuality(value: String) = Unit
- override suspend fun setQuality(resolution: String, bitrateKbps: Int?) = Unit
override suspend fun setAudioLanguage(value: String) = Unit
override suspend fun setVideoGravity(value: String) = Unit
override suspend fun setOrientationMode(value: String) = Unit
diff --git a/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/settings/ServerSettingsFlusherTest.kt b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/settings/ServerSettingsFlusherTest.kt
index f4767015d4..dd16f7992e 100644
--- a/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/settings/ServerSettingsFlusherTest.kt
+++ b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/settings/ServerSettingsFlusherTest.kt
@@ -574,6 +574,58 @@ class ServerSettingsFlusherTest {
assertEquals(1, api.capabilityCalls)
assertEquals(2, api.calls.count { it.key == objectKey })
}
+
+ // ---- Settings revision gate for cellular quality (revision 20) ----------
+
+ private val cellularQuality = SettingKeys.PLAYBACK_CELLULAR_PREFERRED_QUALITY
+ private val cellularBitrate = SettingKeys.PLAYBACK_CELLULAR_MAX_BITRATE_KBPS
+
+ @Test
+ fun `cellular quality is dropped for a server below revision 20`() = runTest {
+ val api = RecordingSettingsApi().apply { manifestRevision(19) }
+ val flusher = DefaultServerSettingsFlusher(api, this, debounceMs = 200)
+
+ flusher.enqueue("p1", cellularQuality, "1080p", serverUrl)
+ flusher.enqueue("p1", cellularBitrate, "3000", serverUrl)
+ flusher.enqueueDelete("p2", cellularQuality, serverUrl)
+ flusher.enqueue("p1", stringKey, "720p", serverUrl)
+ flusher.flushNow()
+
+ // The server would refuse them as unknown_setting; the device keeps them.
+ assertTrue(api.calls.none { it.key == cellularQuality || it.key == cellularBitrate })
+ assertTrue(flusher.pendingKeys("p1").isEmpty() && flusher.pendingKeys("p2").isEmpty(), "dropped, not held")
+ assertTrue(api.calls.any { it.key == stringKey }, "other writes still land")
+ }
+
+ @Test
+ fun `cellular quality is sent to a revision 20 server`() = runTest {
+ val api = RecordingSettingsApi().apply { manifestRevision(20) }
+ val flusher = DefaultServerSettingsFlusher(api, this, debounceMs = 200)
+
+ flusher.enqueue("p1", cellularQuality, "auto", serverUrl)
+ flusher.enqueue("p1", cellularBitrate, "0", serverUrl)
+ flusher.flushNow()
+
+ assertEquals(JsonPrimitive("auto"), api.calls.single { it.key == cellularQuality }.value)
+ assertEquals(JsonNull, api.calls.single { it.key == cellularBitrate }.value, "uncapped is JSON null")
+ }
+
+ @Test
+ fun `cellular quality is held while the revision is unknown`() = runTest {
+ val api = RecordingSettingsApi().apply {
+ capabilities = ApiResult.NetworkError(IllegalStateException("offline"))
+ }
+ val flusher = DefaultServerSettingsFlusher(api, this, debounceMs = 200)
+
+ flusher.enqueue("p1", cellularQuality, "480p", serverUrl)
+ flusher.flushNow()
+ assertTrue(api.calls.isEmpty())
+ assertEquals(setOf(cellularQuality), flusher.pendingKeys("p1"))
+
+ api.manifestRevision(20)
+ flusher.flushNow()
+ assertEquals(JsonPrimitive("480p"), api.calls.single { it.key == cellularQuality }.value)
+ }
}
/**
diff --git a/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/startup/StartupHomeV2Test.kt b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/startup/StartupHomeV2Test.kt
index 73782bf833..ddd04d2715 100644
--- a/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/startup/StartupHomeV2Test.kt
+++ b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/startup/StartupHomeV2Test.kt
@@ -26,7 +26,7 @@ class StartupHomeV2Test {
private var listHook: () -> Unit = {}
private var cacheHook: () -> Unit = {}
private val cache = object : HomeCachePort {
- override suspend fun cacheHomeV2IfAbsent(sections: List, owner: AuthScopeSnapshot, stillCurrent: () -> Boolean) {
+ override suspend fun cacheHomeV2IfAbsent(sections: List, owner: AuthScopeSnapshot, artworkFingerprint: String?, stillCurrent: () -> Boolean) {
assertEquals(this@StartupHomeV2Test.owner, owner); assertTrue(stillCurrent()); writes++; cacheHook()
}
}
diff --git a/androidApp/build.gradle.kts b/androidApp/build.gradle.kts
index f934050f16..f746cc61d1 100644
--- a/androidApp/build.gradle.kts
+++ b/androidApp/build.gradle.kts
@@ -14,19 +14,20 @@ if (file("google-services.json").isFile) {
apply(plugin = "com.google.gms.google-services")
}
-// Play's own track vocabulary, plus the two ways a build reaches a device
-// without Play. Validated so a typo can't reach the server as a header value.
-val siloReleaseChannels = listOf("internal", "alpha", "beta", "production", "sideload", "dev")
+// How a build reaches a device: Google Play, an APK from a GitHub release, or a
+// local debug build. Validated so a typo can't reach the server as a header value.
+val siloReleaseChannels = listOf("release", "sideload", "dev")
val siloVersionName = providers
.gradleProperty("siloVersionName")
.orElse(providers.environmentVariable("PRAIRIE_VERSION_NAME"))
- // Release builds override this from the validated Git tag in
- // android-build.yml. Keep local/dev builds aligned with the latest release.
- .orElse("0.3.11")
+ // Release builds get the store version from scripts/release-version.py
+ // (release.yml). Local builds are 0.0.0 so they never pass for a release.
+ .orElse("0.0.0")
-// The per-marketing-version build counter (TestFlight-style). It is folded into
-// the versionCode by CI, but the app also reports it verbatim to the server
+// The build number: one counter for the repository, never reset (see
+// scripts/release-version.py). It is folded into the versionCode by CI, but the
+// app also reports it verbatim to the server
// (X-Prairie-Client-Build) and shows it on the About row, so it has to survive as
// its own value rather than being reverse-engineered from the versionCode.
val siloBuildNumber = providers
@@ -34,9 +35,9 @@ val siloBuildNumber = providers
.orElse(providers.environmentVariable("PRAIRIE_BUILD_NUMBER"))
.map { value ->
val build = value.toIntOrNull() ?: error("siloBuildNumber must be an integer.")
- // The same 0..999 window release.yml and the Fastfile enforce, so a
- // hand-run build can't stamp a counter the release scheme could never
- // produce. 0 is the unstamped local default; CI itself requires 1..999.
+ // The same window scripts/release-version.py enforces, so a hand-run
+ // build can't stamp a counter the release scheme could never produce.
+ // 0 is the unstamped local default; releases use 1..999.
require(build in 0..999) {
"siloBuildNumber must be between 0 and 999 (0 marks an unstamped local build)."
}
@@ -45,11 +46,10 @@ val siloBuildNumber = providers
// Local/dev builds have no CI build number; 0 marks "not a release build".
.orElse("0")
-// How the artifact reaches a user, reported as X-Prairie-Client-Channel. Release
-// pipelines state it: the Fastfile passes the Play track it is actually
-// uploading to, so a beta-track tester and a production user are told apart
-// rather than both reporting "release". Everything else is a hand-built or
-// sideloaded artifact, which is not on any track.
+// How the artifact reaches a user, reported as X-Prairie-Client-Channel: release.yml
+// stamps "release" on the Google Play bundles and "sideload" on the APKs. Play
+// builds don't name their track, because a store release promotes the tested
+// beta build to production without rebuilding it. Matches prairie-apple's channels.
val siloReleaseChannel = providers
.gradleProperty("siloReleaseChannel")
.orElse(providers.environmentVariable("PRAIRIE_RELEASE_CHANNEL"))
@@ -76,9 +76,9 @@ val siloVersionCode = providers
}
code
}
- // Bump this per release. base -> phone = base*2, TV = base*2+1. base 6 was
- // the 12/13 build; base 7 -> phone 14, TV 15.
- .orElse(7)
+ // Release builds get the base code from scripts/release-version.py
+ // (release.yml); phone = base*2, TV = base*2+1. Local builds use base 1.
+ .orElse(1)
// APK ABI splits (below) are incompatible with the App Bundle build:
// PerModuleBundleTask.getResourcesFile() expects a single processed-resources
diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/PrairieApplication.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/PrairieApplication.kt
index 2060527d2b..fc0b92048d 100644
--- a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/PrairieApplication.kt
+++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/PrairieApplication.kt
@@ -115,6 +115,17 @@ class PrairieApplication : Application(), Configuration.Provider, SingletonImage
}.onFailure {
android.util.Log.w("PrairieApplication", "Home realtime starter init failed", it)
}
+ // Use the active server's local address at home (LocalRouteController).
+ // Guarded โ without it every request stays on the saved address.
+ runCatching {
+ org.prairieserver.prairie.common.network.LocalRouteStarter.create(
+ context = this@PrairieApplication,
+ registry = koinApp.koin.get(),
+ tokenManager = koinApp.koin.get(),
+ ).start(androidx.lifecycle.ProcessLifecycleOwner.get().lifecycle)
+ }.onFailure {
+ android.util.Log.w("PrairieApplication", "Local route starter init failed", it)
+ }
// Drain the user-state outbox (Track B) on launch + when connectivity
// returns. Guarded โ never load-bearing for cold start.
runCatching {
diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/auth/SignOutTeardown.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/auth/SignOutTeardown.kt
index 33bacc64b6..a4e0aef33b 100644
--- a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/auth/SignOutTeardown.kt
+++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/auth/SignOutTeardown.kt
@@ -1,5 +1,8 @@
package org.prairieserver.prairie.android.auth
+import org.prairieserver.prairie.android.push.AndroidPushRegistrar
+import org.prairieserver.prairie.android.push.PushRemoval
+import org.prairieserver.prairie.android.push.tolerateFailure
import org.prairieserver.prairie.common.settings.CardPresentationStore
import org.prairieserver.prairie.common.settings.EpisodeSpoilerStore
import org.prairieserver.prairie.common.settings.LibraryPlaybackPrefsStore
@@ -39,6 +42,10 @@ class SignOutTeardown(
private val metadataAiFeatureStore: MetadataAiFeatureStore,
private val serverRegistry: ServerRegistry,
private val nativeSignIn: NativeSignInCoordinator,
+ /** Removes this device's push registration on a server ([AndroidPushRegistrar.unregisterDevice]). */
+ private val unregisterPush: suspend (serverEntryId: String) -> PushRemoval? = { null },
+ /** Undoes that removal when the sign-out then stops ([AndroidPushRegistrar.restoreRegistration]). */
+ private val restorePush: suspend (PushRemoval) -> Unit = {},
) {
/**
* Push settings still waiting to sync. Call before switching servers so
@@ -54,11 +61,31 @@ class SignOutTeardown(
* server switch. [startSignIn] ("Not you? Switch account" where the
* provider is asked to offer another account) has the login screen that
* follows start the provider sign-in by itself.
+ *
+ * False when another server or login became active while the sign-out
+ * waited on the network (flushing settings, removing the push
+ * registration, ending the server's session): nobody is signed out here
+ * then, as whoever is active now is not who asked to leave.
*/
- suspend fun signOut(flushFirst: Boolean = true, startSignIn: Boolean = false) {
- if (flushFirst) flushPendingSettings()
+ suspend fun signOut(flushFirst: Boolean = true, startSignIn: Boolean = false): Boolean {
+ // Captured before anything it waits on: this is the account that leaves.
val signedOutOf = serverRegistry.activeServerId.value
- authRepository.logout()
+ val target = authRepository.captureSignOutTarget()
+ fun stillActive() = serverRegistry.activeServerId.value == signedOutOf
+ if (flushFirst) flushPendingSettings()
+ if (!stillActive()) return false
+ // While the credentials removing it needs still exist. A registration
+ // left behind keeps the server pushing this device for an account it
+ // no longer holds, but failing to remove it never stops the sign-out.
+ // Cancellation still does, before anyone is signed out.
+ var removal: PushRemoval? = null
+ if (signedOutOf != null) tolerateFailure { removal = unregisterPush(signedOutOf) }
+ if (!stillActive()) {
+ // Removed above, but that server keeps its login, so it keeps its pushes.
+ removal?.let { tolerateFailure { restorePush(it) } }
+ return false
+ }
+ if (!authRepository.logout(target)) return false
nativeSignIn.discardPending()
if (signedOutOf != null) nativeSignIn.requestAccountChoice(signedOutOf, autoStart = startSignIn)
libraryPlaybackPrefsStore.clear()
@@ -70,5 +97,6 @@ class SignOutTeardown(
episodeSpoilerStore.clear()
requestsFeatureStore.reset()
metadataAiFeatureStore.reset()
+ return true
}
}
diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/di/AndroidModule.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/di/AndroidModule.kt
index d9bcd0d446..56c3e4119c 100644
--- a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/di/AndroidModule.kt
+++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/di/AndroidModule.kt
@@ -17,6 +17,9 @@ import org.prairieserver.prairie.common.cast.PrairieCastNsdBrowser
import org.prairieserver.prairie.common.pairing.PairingDeviceId
import org.prairieserver.prairie.common.pairing.CompanionDeviceLoginApprover
import org.prairieserver.prairie.common.pairing.CompanionPairingCoordinator
+import org.prairieserver.prairie.common.discovery.LanServerDiscovery
+import org.prairieserver.prairie.common.discovery.LanServerProbe
+import org.prairieserver.prairie.common.discovery.NsdLanServiceBrowser
import org.prairieserver.prairie.common.pairing.CompanionPairingNsdBrowser
import org.prairieserver.prairie.common.pairing.CompanionPairingServerStore
import org.prairieserver.prairie.common.pairing.CompanionPairingTransportFactory
@@ -171,12 +174,33 @@ val androidModule = module {
syncScheduler = get(),
)
}
+ single {
+ val resources = androidContext().resources
+ val scope = this
+ org.prairieserver.prairie.android.ui.util.PhoneArtworkLayout(
+ screen = {
+ org.prairieserver.prairie.android.ui.util.PhoneArtworkLayout.Screen(
+ resources.configuration.screenWidthDp,
+ resources.configuration.screenHeightDp,
+ resources.displayMetrics.density,
+ )
+ },
+ // The saved card size, seeded from its local cache before the
+ // first reads (startup's Home warm-up runs before the UI loads it).
+ posterScale = {
+ val cards = scope.get()
+ cards.seedFromCache()
+ cards.state.value.presentation.posterSize.posterScale
+ },
+ )
+ }
single {
val tokenManager: TokenManager = get()
org.prairieserver.prairie.common.data.repository.RoomHomeCacheRepository(
db = get(),
snapshotProvider = { tokenManager.snapshotCurrentScope() },
identityTransitions = get(),
+ artwork = get(),
)
}
single {
@@ -185,6 +209,7 @@ val androidModule = module {
db = get(),
snapshotProvider = { tokenManager.snapshotCurrentScope() },
identityTransitions = get(),
+ artwork = get(),
)
}
single {
@@ -233,6 +258,16 @@ val androidModule = module {
}
single { PrairieCastNsdBrowser(androidContext()) }
single { CompanionPairingNsdBrowser(androidContext()) }
+ // Prairie servers on the local network, for server setup. One per screen visit.
+ single { LanServerProbe() }
+ factory {
+ val probe = get()
+ LanServerDiscovery(
+ browser = NsdLanServiceBrowser(androidContext()),
+ probeIdentity = probe::identity,
+ fetchName = probe::name,
+ )
+ }
single { RegistryCompanionPairingServerStore(get(), get()) }
single {
val authRepository = get()
@@ -288,6 +323,14 @@ val androidModule = module {
context = androidContext(),
notificationsRepository = get(),
tokenManager = get(),
+ targets = org.prairieserver.prairie.android.push.PushTargetResolver(
+ installations = { get().installations() },
+ authorities = get(),
+ registry = get(),
+ entriesWithIdentity = { serverId, candidates ->
+ get().entriesFor(serverId, candidates)
+ },
+ ),
)
}
single { PushMessageHandler(presenter = get()) }
@@ -337,10 +380,11 @@ val androidModule = module {
// playback session so the raw phone stream is never cast.
single { PrairieCastSessionManager(androidContext()) }
single {
+ // No network evidence: the phone's measured throughput describes its
+ // own link, not the receiver's, so cast starts carry no estimate.
CastPlaybackPreparer(
playbackRepository = get(),
tokenManager = get(),
- networkEvidenceProvider = get(),
)
}
factory(named("mobileVideoPlaybackStarter")) {
@@ -353,6 +397,7 @@ val androidModule = module {
sessionLifecycle = get(),
reachabilityMonitor = get(),
userItemStatePort = get(),
+ networkEvidenceProvider = get(),
)
}
factory {
@@ -456,6 +501,7 @@ val androidModule = module {
seekIntervalStore = get(),
activeProfileStore = get(),
shuffleFeatureStore = get(),
+ networkEvidenceProvider = get(),
)
}
viewModel { HomeViewModel(get(), get(), get(), get(), getOrNull(), get(), get(), catalogLanguage = get()) }
@@ -512,12 +558,9 @@ val androidModule = module {
viewModel { HistoryViewModel(get(), get()) }
viewModel { CollectionsViewModel(get()) }
viewModel { params -> CollectionDetailViewModel(get(), get(), params.get()) }
- viewModel { RequestsViewModel(get(), get()) }
+ viewModel { RequestsViewModel(get()) }
viewModel { RequestSearchViewModel(get()) }
viewModel { MyRequestsViewModel(get()) }
- viewModel { params ->
- org.prairieserver.prairie.viewmodel.RequestApprovalsViewModel(get(), loadOnInit = params.getOrNull() ?: true)
- }
// Platform supplies "today" and the IANA timezone; the shared ViewModel's
// week math stays deterministic in commonTest (no Clock.System default).
viewModel {
@@ -538,7 +581,11 @@ val androidModule = module {
)
}
single {
- org.prairieserver.prairie.android.auth.SignOutTeardown(get(), get(), get(), get(), get(), get(), get(), get(), get(), get(), get(), get(), get())
+ org.prairieserver.prairie.android.auth.SignOutTeardown(
+ get(), get(), get(), get(), get(), get(), get(), get(), get(), get(), get(), get(), get(),
+ unregisterPush = { get().unregisterDevice(it) },
+ restorePush = { get().restoreRegistration(it) },
+ )
}
single {
org.prairieserver.prairie.android.auth.ProfileSwitchTeardown(get(), get(), get(), get(), get(), get(), get())
@@ -594,7 +641,13 @@ val androidModule = module {
viewModel { ProfileSelectionViewModel(get(), get(), profileVerificationRecovery = getOrNull()) }
viewModel { CreateProfileViewModel(get()) }
viewModel { EditProfileViewModel(get()) }
- viewModel { ServerListViewModel(get(), get(), get(), get(), get()) }
+ viewModel {
+ ServerListViewModel(
+ get(), get(), get(), get(), get(),
+ unregisterPush = { get().unregisterDevice(it) },
+ restorePush = { get().restoreRegistration(it) },
+ )
+ }
viewModel { params ->
val args = params.get>()
val authRepository = get()
diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/push/AndroidPushRegistrar.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/push/AndroidPushRegistrar.kt
index bd3a3a2aad..dae140750a 100644
--- a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/push/AndroidPushRegistrar.kt
+++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/push/AndroidPushRegistrar.kt
@@ -2,12 +2,13 @@ package org.prairieserver.prairie.android.push
import java.security.SecureRandom
import java.util.Base64
-import java.util.concurrent.atomic.AtomicReference
+import java.util.concurrent.ConcurrentHashMap
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
+import kotlinx.coroutines.withTimeoutOrNull
import org.prairieserver.prairie.model.notifications.*
import org.prairieserver.prairie.network.*
import org.prairieserver.prairie.repository.PushRegistrationRepository
@@ -22,46 +23,252 @@ class AndroidPushRegistrar internal constructor(
Base64.getUrlEncoder().withoutPadding().encodeToString(ByteArray(32).also { SecureRandom().nextBytes(it) })
},
) {
- private val mutex = Mutex()
- private val latestRequest = AtomicReference()
- private fun beginRequest(): Any = Any().also(latestRequest::set)
+ /**
+ * Guards this device's installation records: each read, and each update of one
+ * server's record, as a single read-modify-write. Never held across a request,
+ * so marking a leaving login or answering a push never waits on a slow server.
+ */
+ private val recordsLock = Mutex()
+ /** One command at a time per saved server, so its intents reach it in order; servers never wait on each other. */
+ private val serverLocks = ConcurrentHashMap()
+ private fun serverLock(serverUrl: String) = serverLocks.getOrPut(serverUrl) { Mutex() }
+ /** The newest operation per saved server: a newer one supersedes an older one there, never another server's. */
+ private val latestRequests = ConcurrentHashMap()
+ private fun beginRequest(serverId: String): Any = Any().also { latestRequests[serverId] = it }
+ private fun isLatest(owner: DurableLoginAuthority, attempt: Any) = latestRequests[owner.scope.serverId] === attempt
private suspend fun capture() = authorities.snapshotDurableLoginAuthority()?.takeIf {
it.loginId.isNotBlank() && !it.scope.profileId.isNullOrBlank() && it.scope.credentialGenerationId == null
}
- private suspend fun current(owner: DurableLoginAuthority) = owner == capture()
+ /** A saved-login owner ([unregisterDevice]) is current while that server's login is, active or not. */
+ private suspend fun current(owner: DurableLoginAuthority): Boolean {
+ val live = if (owner.scope.isGenerationPinned) {
+ authorities.savedLoginAuthority(owner.scope.serverId, requireNotNull(owner.scope.profileId), owner.scope.profileToken)
+ } else capture()
+ return owner == live
+ }
+
+ /** Last records read or written under [recordsLock], so a push never waits on the store either. */
+ @Volatile private var known: List? = null
+ private suspend fun readLocked() = store.read().also { known = it }
+
+ /** This device's installation records, one per server it has registered with. */
+ internal suspend fun installations(): List = known ?: recordsLock.withLock { readLocked() }
+
+ private fun List.recordOf(serverUrl: String, device: String): PushInstallation? =
+ filter { it.serverUrl == serverUrl && it.deviceId == device }.also { check(it.size <= 1) }.singleOrNull()
+
+ /** [serverUrl]'s record for [device], as stored now. */
+ private suspend fun recordOf(serverUrl: String, device: String): PushInstallation? =
+ recordsLock.withLock { readLocked() }.recordOf(serverUrl, device)
+
+ /** Replaces [serverUrl]'s record for [device] with [change] of it, leaving every other server's as stored. */
+ private suspend fun updateRecord(serverUrl: String, device: String, change: (PushInstallation?) -> PushInstallation?) =
+ recordsLock.withLock {
+ val records = readLocked()
+ val stored = records.recordOf(serverUrl, device)
+ val updated = change(stored)
+ if (updated !== stored) {
+ val next = records.filterNot { it === stored } + listOfNotNull(updated)
+ store.write(next)
+ known = next
+ }
+ }
+
private fun failure(code: String) = ApiResult.Error(0, code, "Push registration requires its original installation and login authority.")
suspend fun registerIfAvailable(): ApiResult? {
- val attempt = beginRequest()
val owner = capture() ?: return null
+ val attempt = beginRequest(owner.scope.serverId)
val token = tokenProvider.token()?.trim()?.takeIf { it.isNotEmpty() } ?: return null
if (!current(owner)) return failure("push_authority_changed")
return register(token, owner, attempt)
}
- suspend fun registerToken(token: String): ApiResult {
- val attempt = beginRequest()
- val owner = capture() ?: return failure("push_authority_changed")
- return register(token.trim(), owner, attempt)
+ /**
+ * Registers [token] with the active server for [owner], the active login the
+ * caller captured (it must still be active when the registration applies),
+ * or whoever is active now.
+ */
+ suspend fun registerToken(token: String, owner: DurableLoginAuthority? = null): ApiResult {
+ val active = owner ?: capture() ?: return failure("push_authority_changed")
+ return register(token.trim(), active, beginRequest(active.scope.serverId))
+ }
+ /**
+ * Registers a rotated FCM [token] with every saved server this device is
+ * registered with: the active one for whoever is active there, then each
+ * other one as the login and profile it was registered for. FCM allows this
+ * little time, so it may stop short; [registerOtherServersIfBehind] finishes
+ * the rest on the next foreground.
+ */
+ suspend fun registerRotatedToken(token: String) {
+ val rotated = token.trim()
+ // For whoever was active when the rotation began: should that change first,
+ // the attempt stops, and the server is brought up to the new token below
+ // like every other one that does not have it yet.
+ capture()?.let { registerToken(rotated, it) }
+ registerOtherServers(rotated, exceptServerId = null)
+ }
+
+ /**
+ * Brings every saved server but the active one up to the current FCM token,
+ * as the login and profile each was registered for: whatever a rotation ran
+ * out of time for, or left uncertain, is finished here.
+ */
+ suspend fun registerOtherServersIfBehind() {
+ val token = tokenProvider.token()?.trim()?.takeIf { it.isNotEmpty() } ?: return
+ registerOtherServers(token, exceptServerId = capture()?.scope?.serverId)
+ }
+
+ /** Each server's attempt is bounded on its own, so one that fails or stalls costs only itself. */
+ private suspend fun registerOtherServers(token: String, exceptServerId: String?) {
+ val device = deviceIdProvider()
+ val behind = installations().filter {
+ val registration = it.intent.registration
+ it.deviceId == device && it.intent.serverId != exceptServerId && it.refusal == null &&
+ registration != null && (registration.token != token || !it.acknowledged)
+ }
+ for (installation in behind) {
+ withTimeoutOrNull(PUSH_WORK_BUDGET_MS) { registerAsRegisteredOwner(installation, token) }
+ }
+ }
+
+ private suspend fun registerAsRegisteredOwner(behind: PushInstallation, token: String): ApiResult {
+ if (token.length !in 64..512) return failure("invalid_push_token")
+ // Joins that server's newest operation rather than starting a newer one:
+ // bringing a token up to date must never supersede a removal under way there.
+ val attempt = latestRequests.getOrPut(behind.intent.serverId) { Any() }
+ return serverLock(behind.serverUrl).withLock {
+ // Renews only the registration found behind, never one a login on its way
+ // out holds. Anything that replaced it since (another profile, a newer
+ // token, a removal) leaves this stale.
+ val record = recordOf(behind.serverUrl, behind.deviceId)
+ if (record == null || record.generation != behind.generation || record.intent != behind.intent || record.ownerLeaving) {
+ return@withLock failure("push_registration_changed")
+ }
+ val owner = registeredOwnerOf(record) ?: return@withLock failure("push_authority_changed")
+ applyOnServer(owner, checkNotNull(record.intent.registration).copy(token = token), attempt, mayReconcile = true)
+ }
}
+
+ /** [installation]'s server's saved login acting as the profile it was registered for; null once that login is gone. */
+ private suspend fun registeredOwnerOf(installation: PushInstallation): DurableLoginAuthority? =
+ authorities.savedLoginAuthority(installation.intent.serverId, installation.intent.profileId, installation.intent.profileToken)
+ ?.takeIf { it.loginId == installation.intent.loginId && it.scope.serverUrl == installation.serverUrl }
+
private suspend fun register(token: String, owner: DurableLoginAuthority, attempt: Any): ApiResult {
if (token.length !in 64..512) return failure("invalid_push_token")
val result = apply(owner, PushDeviceRegisterRequest("android", token, deviceIdProvider()), attempt)
return result.map { checkNotNull(it) }
}
- suspend fun unregisterDevice(): ApiResult {
- val attempt = beginRequest()
- val owner = capture() ?: return failure("push_authority_changed")
- return apply(owner, null, attempt).map { Unit }
+ /**
+ * Removes this device's registration on [serverId], active or not, before that
+ * server's credentials are cleared. Acts as the login and profile it was
+ * registered for: the server refuses a removal from any other profile, and a
+ * refusal is kept for good. The record stays, as the server binds its key to
+ * this device; a removal intent is no push target.
+ *
+ * Bounded so an unreachable server cannot hold up the sign-out or removal
+ * waiting on it, and never throws, so unreadable records cannot abort it
+ * either. A removal cut off mid-flight stays pending, like any uncertain one.
+ */
+ suspend fun unregisterDevice(serverId: String): PushRemoval {
+ val attempt = beginRequest(serverId)
+ val result = try {
+ withTimeoutOrNull(UNREGISTER_BUDGET_MS) { removeAsRegisteredOwner(serverId, attempt) } ?: failure("push_unregister_timed_out")
+ } catch (cancelled: CancellationException) {
+ throw cancelled
+ } catch (_: Exception) {
+ failure("push_persistence_unavailable")
+ }
+ return PushRemoval(serverId, attempt, result)
+ }
+
+ /**
+ * Undoes [removal] after the sign-out or server removal that made it stopped,
+ * leaving that server's login in place: registers again as the login and
+ * profile it was registered for, with the current token. Only that removal
+ * is undone, and only while it is still the newest operation on that server,
+ * so a later removal (another sign-out, the server's removal) stands; and
+ * nothing happens once that login is gone, so a removal whose login really
+ * was cleared stays removed. Bounded and never throws, like [unregisterDevice].
+ */
+ suspend fun restoreRegistration(removal: PushRemoval): ApiResult = try {
+ withTimeoutOrNull(UNREGISTER_BUDGET_MS) { restoreAsRegisteredOwner(removal) } ?: failure("push_restore_timed_out")
+ } catch (cancelled: CancellationException) {
+ throw cancelled
+ } catch (_: Exception) {
+ failure("push_persistence_unavailable")
+ }
+
+ private suspend fun restoreAsRegisteredOwner(removal: PushRemoval): ApiResult {
+ val serverId = removal.serverId
+ // Continues the removal's own operation instead of starting one, so anything
+ // started on that server since supersedes this, here or under its lock.
+ val attempt = removal.attempt
+ if (latestRequests[serverId] !== attempt) return failure("push_request_superseded")
+ val device = deviceIdProvider()
+ val token = tokenProvider.token()?.trim()?.takeIf { it.length in 64..512 } ?: return failure("invalid_push_token")
+ val found = installations().firstOrNull { it.intent.serverId == serverId && it.deviceId == device && it.refusal == null }
+ ?: return failure("push_not_registered")
+ return serverLock(found.serverUrl).withLock {
+ val record = recordOf(found.serverUrl, device)?.takeIf { it.intent.serverId == serverId && it.refusal == null }
+ ?: return@withLock failure("push_not_registered")
+ val owner = registeredOwnerOf(record) ?: return@withLock failure("push_authority_changed")
+ // The registration it held, or the one register() sends, with the current token.
+ val request = record.intent.registration?.copy(token = token) ?: PushDeviceRegisterRequest("android", token, device)
+ applyOnServer(owner, request, attempt, mayReconcile = true)
+ }
+ }
+
+ private suspend fun removeAsRegisteredOwner(serverId: String, attempt: Any): ApiResult {
+ val device = deviceIdProvider()
+ val found = installations().firstOrNull { it.intent.serverId == serverId && it.deviceId == device && it.removable }
+ ?: return failure("push_not_registered")
+ // Before waiting on anything: should the removal not finish, whatever this
+ // record then holds belongs to a login about to be cleared.
+ markOwnerLeaving(found.serverUrl, device)
+ return serverLock(found.serverUrl).withLock {
+ // Read again under the server's lock: a registration finishing first can
+ // change the registered profile, and the server refuses a removal from any
+ // other profile, for good.
+ val registered = recordOf(found.serverUrl, device)?.takeIf { it.intent.serverId == serverId && it.removable }
+ ?: return@withLock failure("push_not_registered")
+ val owner = registeredOwnerOf(registered) ?: return@withLock failure("push_authority_changed")
+ applyOnServer(owner, null, attempt, mayReconcile = true).map { Unit }
+ }
}
+ /**
+ * A registration, or a removal not yet confirmed, which is then sent again
+ * exactly as persisted: one cut off before it reached the server must not
+ * be taken for done.
+ */
+ private val PushInstallation.removable: Boolean
+ get() = refusal == null && (intent.registration != null || !acknowledged)
+
+ /**
+ * Marks [serverUrl]'s record as belonging to a login on its way out, so the
+ * next registration may supersede whatever it leaves uncertain: once that
+ * login is cleared, nobody can replay it exactly.
+ */
+ private suspend fun markOwnerLeaving(serverUrl: String, device: String) =
+ updateRecord(serverUrl, device) { record -> record?.takeUnless { it.ownerLeaving }?.copy(ownerLeaving = true) ?: record }
+
+ /**
+ * A record acknowledged for a login that is still current is nobody's leftover:
+ * catching up a rotated token must reach that server again.
+ */
+ private suspend fun clearOwnerLeaving(serverUrl: String, device: String, generation: Long) =
+ updateRecord(serverUrl, device) { record -> record?.takeIf { it.generation == generation }?.copy(ownerLeaving = false) ?: record }
+
private suspend fun apply(owner: DurableLoginAuthority, request: PushDeviceRegisterRequest?, attempt: Any): ApiResult =
- mutex.withLock { applyLocked(owner, request, attempt, mayReconcile = true) }
+ serverLock(owner.scope.serverUrl).withLock { applyOnServer(owner, request, attempt, mayReconcile = true) }
- private suspend fun applyLocked(owner: DurableLoginAuthority, request: PushDeviceRegisterRequest?, attempt: Any,
+ /** Applies one intent to [owner]'s server; the caller holds that server's lock. */
+ private suspend fun applyOnServer(owner: DurableLoginAuthority, request: PushDeviceRegisterRequest?, attempt: Any,
mayReconcile: Boolean): ApiResult {
try {
if (!current(owner)) return failure("push_authority_changed")
- if (latestRequest.get() !== attempt) return failure("push_request_superseded")
+ if (!isLatest(owner, attempt)) return failure("push_request_superseded")
val device = request?.deviceId ?: deviceIdProvider()
if (device.isBlank() || device.length > 128) return failure("invalid_push_device")
val available = repository.available(owner.scope)
@@ -71,14 +278,19 @@ class AndroidPushRegistrar internal constructor(
else -> failure("push_registration_unavailable")
}
if (!current(owner)) return failure("push_authority_changed")
- val records = store.read()
+ val serverUrl = owner.scope.serverUrl
+ val previous = recordOf(serverUrl, device)
if (!current(owner)) return failure("push_authority_changed")
- if (latestRequest.get() !== attempt) return failure("push_request_superseded")
- val matches = records.filter { it.serverUrl == owner.scope.serverUrl && it.deviceId == device }
- check(matches.size <= 1)
- val previous = matches.singleOrNull()
+ if (!isLatest(owner, attempt)) return failure("push_request_superseded")
val intent = PushInstallationIntent(owner.scope.serverId, owner.loginId, requireNotNull(owner.scope.profileId), owner.scope.profileToken, request)
- val reconciling = previous != null && !previous.acknowledged && previous.intent != intent
+ // An uncertain removal, or an uncertain registration whose login was leaving,
+ // is superseded rather than replayed: the server takes a registration at a
+ // higher generation whether the uncertain intent landed or never arrived, and
+ // the login that sent it is usually gone, as sign-out removes first and then
+ // clears it. Any other uncertain registration is still replayed exactly.
+ val supersedesUncertainty = request != null && previous != null && !previous.acknowledged &&
+ (previous.intent.registration == null || previous.ownerLeaving)
+ val reconciling = previous != null && !previous.acknowledged && previous.intent != intent && !supersedesUncertainty
previous?.let {
check(it.generation > 0 && it.key.matches(Regex("[A-Za-z0-9_-]{43}")))
if (it.acknowledged && it.intent.registration != null) {
@@ -91,7 +303,10 @@ class AndroidPushRegistrar internal constructor(
it.intent.profileId != intent.profileId || it.intent.profileToken != intent.profileToken)) {
return failure("push_previous_intent_uncertain")
}
- if (it.acknowledged && it.intent == intent) return ApiResult.Success(it.receipt)
+ if (it.acknowledged && it.intent == intent) {
+ if (it.ownerLeaving) clearOwnerLeaving(serverUrl, device, it.generation)
+ return ApiResult.Success(it.receipt)
+ }
}
val entry = if (previous != null && (previous.intent == intent || reconciling)) previous else {
if (previous?.generation == Long.MAX_VALUE) return failure("push_generation_exhausted")
@@ -99,10 +314,12 @@ class AndroidPushRegistrar internal constructor(
(previous?.generation ?: 0) + 1, intent)
}
check(entry.key.matches(Regex("[A-Za-z0-9_-]{43}")))
- val retained = records.filterNot { it.serverUrl == entry.serverUrl && it.deviceId == entry.deviceId }
if (!current(owner)) return failure("push_authority_changed")
// Allocation reaches durable storage before any mutation can leave the process.
- store.write(retained + entry)
+ // A login marked leaving meanwhile stays marked on the intent it allocates next.
+ if (entry !== previous) updateRecord(serverUrl, device) { stored ->
+ entry.copy(ownerLeaving = stored?.ownerLeaving == true && stored.intent.loginId == entry.intent.loginId)
+ }
currentCoroutineContext().ensureActive()
if (!current(owner)) return failure("push_authority_changed")
val persistedRequest = entry.intent.registration
@@ -110,11 +327,14 @@ class AndroidPushRegistrar internal constructor(
repository.unregisterDevice(device, entry.key, entry.generation, owner.scope).map { null }
} else repository.registerAndroidDevice(persistedRequest, entry.key, entry.generation, owner.scope)
if (!current(owner)) return failure("push_authority_changed")
+ suspend fun settle(outcome: (PushInstallation) -> PushInstallation) = updateRecord(serverUrl, device) { stored ->
+ stored?.takeIf { it.generation == entry.generation && it.key == entry.key }?.let(outcome) ?: stored
+ }
when (result) {
- is ApiResult.Success -> store.write(retained + entry.copy(acknowledged = true, receipt = result.data))
+ is ApiResult.Success -> settle { it.copy(acknowledged = true, receipt = result.data, ownerLeaving = false) }
is ApiResult.Error -> if (result.code in setOf(401, 403, 409, 422)) {
// No generation rebase or replacement secret after a definite refusal.
- store.write(retained + entry.copy(refusal = "push_registration_refused"))
+ settle { it.copy(refusal = "push_registration_refused") }
}
is ApiResult.NetworkError -> Unit // Exact persisted intent is the only retry candidate.
}
@@ -122,7 +342,7 @@ class AndroidPushRegistrar internal constructor(
// At most one exact pending replay precedes the new desired intent. Never
// substitute a new token, verb, login or generation into that replay.
return if (reconciling && result is ApiResult.Success) {
- applyLocked(owner, request, attempt, mayReconcile = false)
+ applyOnServer(owner, request, attempt, mayReconcile = false)
} else result
} catch (cancelled: CancellationException) {
throw cancelled
@@ -130,4 +350,28 @@ class AndroidPushRegistrar internal constructor(
return failure("push_persistence_unavailable")
}
}
+
+ private companion object {
+ const val UNREGISTER_BUDGET_MS = 8_000L
+ }
+}
+
+/** One removal [AndroidPushRegistrar.unregisterDevice] made, which [AndroidPushRegistrar.restoreRegistration] can undo. */
+class PushRemoval internal constructor(
+ val serverId: String,
+ internal val attempt: Any,
+ val result: ApiResult,
+)
+
+/**
+ * Runs a push registration step for a sign-out or server removal, which carries
+ * on whatever the step throws. Cancellation still propagates and stops it.
+ */
+internal suspend fun tolerateFailure(step: suspend () -> Unit) {
+ try {
+ step()
+ } catch (cancelled: CancellationException) {
+ throw cancelled
+ } catch (_: Exception) {
+ }
}
diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/push/AndroidPushRegistrationStarter.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/push/AndroidPushRegistrationStarter.kt
index 684cf85f0f..2f902fa67a 100644
--- a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/push/AndroidPushRegistrationStarter.kt
+++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/push/AndroidPushRegistrationStarter.kt
@@ -40,6 +40,8 @@ class AndroidPushRegistrationStarter(
private fun CoroutineScope.registerOnce() {
launch {
runCatching { registrar.registerIfAvailable() }
+ // Saved servers a token rotation did not reach in time.
+ runCatching { registrar.registerOtherServersIfBehind() }
}
}
}
diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/push/PrairieFirebaseMessagingService.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/push/PrairieFirebaseMessagingService.kt
index 5811d7cc60..965cb74a94 100644
--- a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/push/PrairieFirebaseMessagingService.kt
+++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/push/PrairieFirebaseMessagingService.kt
@@ -24,6 +24,7 @@ class PushMessageHandler(
}
presenter.present(
deliveryId = deliveryId,
+ issuer = issuerFrom(data),
fallbackTitle = fallbackTitle ?: data["title"],
fallbackBody = fallbackBody ?: data["body"],
)
@@ -35,13 +36,29 @@ class PushMessageHandler(
data[key]?.trim()?.takeIf { it.isNotEmpty() }
}
+ /**
+ * Null only when the push names neither, as servers and relays that predate
+ * them do. Naming one without the other still says the push may be another
+ * server's, so it never falls back to the active one: its blank half matches
+ * no target.
+ */
+ private fun issuerFrom(data: Map): PushIssuer? {
+ if (SERVER_ID_KEY !in data && PROFILE_ID_KEY !in data) return null
+ return PushIssuer(data[SERVER_ID_KEY]?.trim().orEmpty(), data[PROFILE_ID_KEY]?.trim().orEmpty())
+ }
+
companion object {
- // The Prairie push relay sends data-only messages carrying exactly
- // prairie_delivery_id and prairie_mode; the legacy keys stay as fallbacks.
+ // The Prairie push relay sends data-only messages carrying prairie_delivery_id
+ // and prairie_mode, plus prairie_server_id and prairie_profile_id from servers
+ // that send them; the legacy delivery keys stay as fallbacks.
const val PRAIRIE_DELIVERY_ID_KEY = "prairie_delivery_id"
const val DELIVERY_ID_KEY = "delivery_id"
const val MODE_KEY = "prairie_mode"
const val MODE_BACKGROUND_WAKE = "background_wake"
+
+ /** The server deployment and profile the push was sent for. */
+ const val SERVER_ID_KEY = "prairie_server_id"
+ const val PROFILE_ID_KEY = "prairie_profile_id"
private val DELIVERY_ID_KEYS =
listOf(PRAIRIE_DELIVERY_ID_KEY, DELIVERY_ID_KEY, "deliveryId", "deliveryID", "id")
}
@@ -92,7 +109,8 @@ internal suspend fun withinPushBudget(block: suspend () -> T): T? =
*
* Both callbacks run on the SDK's intent-handling executor (never the main
* thread), so running inline is safe; [PUSH_WORK_BUDGET_MS] keeps each one
- * inside the window FCM allows.
+ * inside the window FCM allows. A token rotation reaches the saved servers it
+ * can within that one budget; the next foreground registers the rest.
*/
class PrairieFirebaseMessagingService : FirebaseMessagingService() {
@@ -117,7 +135,7 @@ class PrairieFirebaseMessagingService : FirebaseMessagingService() {
val registrar = runCatching { get(AndroidPushRegistrar::class.java) }.getOrNull()
?: return
runBlocking {
- withinPushBudget { registrar.registerToken(token) }
+ withinPushBudget { registrar.registerRotatedToken(token) }
}
}
}
diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/push/PushInstallationStore.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/push/PushInstallationStore.kt
index b5985f416f..1785e5670b 100644
--- a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/push/PushInstallationStore.kt
+++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/push/PushInstallationStore.kt
@@ -22,6 +22,12 @@ internal data class PushInstallation(
val acknowledged: Boolean = false,
val receipt: PushDeviceRegisterResponse? = null,
val refusal: String? = null,
+ /**
+ * This registration is uncertain and its login is being signed out (or its
+ * server removed), so nobody will be left to replay it exactly: the next
+ * registration supersedes it instead.
+ */
+ val ownerLeaving: Boolean = false,
) { override fun toString() = "PushInstallation()" }
@Serializable
diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/push/PushNotificationPresenter.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/push/PushNotificationPresenter.kt
index 4d8ea48cbf..4eb501a2eb 100644
--- a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/push/PushNotificationPresenter.kt
+++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/push/PushNotificationPresenter.kt
@@ -19,27 +19,22 @@ import org.prairieserver.prairie.network.ApiResult
import org.prairieserver.prairie.network.TokenManager
import org.prairieserver.prairie.repository.NotificationsRepository
-class PushNotificationPresenter(
+class PushNotificationPresenter internal constructor(
private val context: Context,
private val notificationsRepository: NotificationsRepository,
private val tokenManager: TokenManager,
+ private val targets: PushTargetResolver,
) {
suspend fun present(
deliveryId: String,
+ issuer: PushIssuer? = null,
fallbackTitle: String? = null,
fallbackBody: String? = null,
) {
- // Captured BEFORE the fetch, which can take seconds: reading identity
- // afterwards attributed the notification to whatever the user had
- // switched to in the meantime. The scope snapshot (not just the server
- // id) is what detects an AโBโA round trip across the fetch, which a
- // plain id comparison reports as "unchanged".
- val scopeBeforeFetch = tokenManager.snapshotCurrentScope()
-
// Fetch before the permission check: on a direct-lookup miss the fallback
// refreshes the inbox, which keeps the in-app badge current even for a
// profile that has denied POST_NOTIFICATIONS.
- val row = fetch(deliveryId)
+ val (row, attribution) = if (issuer != null) fetchForIssuer(deliveryId, issuer) else fetchForActiveScope(deliveryId)
if (!canPostNotifications()) return
ensureChannel()
@@ -54,25 +49,6 @@ class PushNotificationPresenter(
// identity is worse than none: a null component is treated as a
// wildcard at delivery, so a half-attributed notification can act under
// an identity that never generated it.
- //
- // The fetched row is authoritative for the profile โ it IS the row from
- // that profile's inbox. The server is only trusted if the scope did not
- // move across the fetch.
- //
- // KNOWN LIMIT: the push payload carries no issuing server, and the FCM
- // token stays registered with previously-active servers, so a push from
- // server A arriving while B is active simply misses its lookup โ it
- // cannot be attributed to A at all, and is posted non-navigable below.
- // Attributing it to B is what this used to do, and is wrong. Fixing it
- // properly needs issuer fields in the push protocol: server-side work.
- val scopeAfterFetch = tokenManager.snapshotCurrentScope()
- val attribution = pushNotificationAttribution(
- rowProfileId = row?.profileId,
- serverIdBefore = scopeBeforeFetch?.serverId,
- identityGenerationBefore = scopeBeforeFetch?.identityGeneration,
- serverIdAfter = scopeAfterFetch?.serverId,
- identityGenerationAfter = scopeAfterFetch?.identityGeneration,
- )
val issuingServerId = attribution?.serverId
val issuingProfileId = attribution?.profileId
val attributable = attribution != null
@@ -92,7 +68,9 @@ class PushNotificationPresenter(
putExtra(EXTRA_DELIVERY_ID, deliveryId)
// Only navigable when we know whose it is. Unattributable ones still
// post โ the user should see the event โ but tapping just opens the
- // app rather than acting on someone else's library.
+ // app rather than acting on someone else's library. An attributed
+ // one opens its route only while its server and profile are the
+ // active ones (MainActivity); otherwise it opens the app.
if (attributable) {
putExtra(EXTRA_NAV_ROUTE, content.route)
putExtra(EXTRA_SERVER_ID, issuingServerId)
@@ -121,6 +99,64 @@ class PushNotificationPresenter(
}
}
+ /**
+ * A push that names its server and profile is read with that registration's
+ * saved login, whichever server and profile are active. It belongs to that
+ * identity only once that server returns the row for that profile: the push
+ * names its issuer itself, and any server can name any pair. Otherwise, as
+ * for a pair this device holds no registration and login for (which is not
+ * fetched at all), it posts generic and non-navigable.
+ */
+ private suspend fun fetchForIssuer(deliveryId: String, issuer: PushIssuer): FetchedNotification {
+ val (target, row) = withinPushBudget {
+ val target = targets.resolve(issuer) ?: return@withinPushBudget null
+ val row = (notificationsRepository.get(deliveryId, target.owner.scope) as? ApiResult.Success)?.data
+ row?.takeIf { it.profileId == target.profileId }?.let { target to it }
+ } ?: return FetchedNotification(row = null, attribution = null)
+ // Signed out of that server (or into another account there) during the
+ // read: nobody here is left to show it to.
+ if (tokenManager.loginSessionId(target.serverEntryId) != target.owner.loginId) {
+ return FetchedNotification(row = null, attribution = null)
+ }
+ return FetchedNotification(
+ row = row,
+ attribution = PushNotificationAttribution(serverId = target.serverEntryId, profileId = target.profileId),
+ )
+ }
+
+ /** A push from a server or relay that predates naming its issuer: read as the active identity. */
+ private suspend fun fetchForActiveScope(deliveryId: String): FetchedNotification {
+ // Captured BEFORE the fetch, which can take seconds: reading identity
+ // afterwards attributed the notification to whatever the user had
+ // switched to in the meantime. The scope snapshot (not just the server
+ // id) is what detects an AโBโA round trip across the fetch, which a
+ // plain id comparison reports as "unchanged".
+ val scopeBeforeFetch = tokenManager.snapshotCurrentScope()
+ val row = fetch(deliveryId)
+
+ // The fetched row is authoritative for the profile โ it IS the row from
+ // that profile's inbox. The server is only trusted if the scope did not
+ // move across the fetch.
+ //
+ // KNOWN LIMIT: this payload names no issuing server, and the FCM token
+ // stays registered with previously-active servers, so a push from
+ // server A arriving while B is active simply misses its lookup โ it
+ // cannot be attributed to A at all, and is posted non-navigable.
+ // Attributing it to B is what this used to do, and is wrong. Servers
+ // that name the issuer take [fetchForIssuer] instead.
+ val scopeAfterFetch = tokenManager.snapshotCurrentScope()
+ return FetchedNotification(
+ row = row,
+ attribution = pushNotificationAttribution(
+ rowProfileId = row?.profileId,
+ serverIdBefore = scopeBeforeFetch?.serverId,
+ identityGenerationBefore = scopeBeforeFetch?.identityGeneration,
+ serverIdAfter = scopeAfterFetch?.serverId,
+ identityGenerationAfter = scopeAfterFetch?.identityGeneration,
+ ),
+ )
+ }
+
// sync refreshes the inbox for a background_wake push without posting a
// visible notification.
suspend fun sync() {
@@ -229,6 +265,11 @@ class PushNotificationPresenter(
}
}
+private data class FetchedNotification(
+ val row: NotificationRow?,
+ val attribution: PushNotificationAttribution?,
+)
+
private data class PushNotificationContent(
val title: String,
val body: String,
diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/push/PushTargetResolver.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/push/PushTargetResolver.kt
new file mode 100644
index 0000000000..8a409b46e0
--- /dev/null
+++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/push/PushTargetResolver.kt
@@ -0,0 +1,76 @@
+package org.prairieserver.prairie.android.push
+
+import org.prairieserver.prairie.model.server.ServerEntry
+import org.prairieserver.prairie.network.DurableLoginAuthority
+import org.prairieserver.prairie.network.DurableLoginAuthorityProvider
+import org.prairieserver.prairie.network.ServerRegistry
+
+/**
+ * The server and profile a push names, as `prairie_server_id` and `prairie_profile_id`;
+ * blank when it named only the other.
+ */
+data class PushIssuer(val serverId: String, val profileId: String)
+
+/**
+ * Where a push's content lives: the saved server entry ([serverEntryId]) and
+ * profile it was sent for, and the saved login that reads it, whichever server
+ * and profile are active.
+ */
+internal data class PushTarget(
+ val serverEntryId: String,
+ val profileId: String,
+ val owner: DurableLoginAuthority,
+)
+
+/**
+ * Finds the registration a push was sent for. Each saved server's
+ * installation record is that server's target: it holds the profile the
+ * server pushes for and the proof of that profile, and the server's saved
+ * login supplies the rest. A server's deployment identity is its own claim:
+ * the one its registration receipt reported, else the one recorded on its
+ * entry, else (servers registered before receipts carried it) what its saved
+ * address answers now, recorded for next time. It is never a key: no server's
+ * claim reaches another saved server's record.
+ *
+ * Null for a server or profile this device holds no live registration and
+ * login for, which includes one signed out of, removed, or unregistered, and
+ * for one that more than one saved server claims.
+ */
+internal class PushTargetResolver(
+ private val installations: suspend () -> List,
+ private val authorities: DurableLoginAuthorityProvider,
+ private val registry: ServerRegistry,
+ /** The entries among the candidates whose identity is the server id; may probe them. */
+ private val entriesWithIdentity: suspend (serverId: String, candidates: List) -> List,
+) {
+ suspend fun resolve(issuer: PushIssuer): PushTarget? {
+ if (issuer.serverId.isBlank() || issuer.profileId.isBlank()) return null
+ val entries = registry.entries.value
+ fun PushInstallation.entry() = entries.firstOrNull { it.id == intent.serverId && it.url == serverUrl }
+ fun PushInstallation.identity() = receipt?.serverId ?: entry()?.verifiedServerId?.takeIf { it.isNotBlank() }
+
+ val registered = installations().filter {
+ it.acknowledged && it.refusal == null && it.intent.registration != null &&
+ it.intent.profileId == issuer.profileId && it.entry() != null
+ }
+ // Every claim is counted, so ask the servers that have made none yet
+ // too: otherwise another server's claim would stand in for theirs.
+ val unidentified = registered.filter { it.identity() == null }
+ val answered = if (unidentified.isEmpty()) emptySet() else {
+ entriesWithIdentity(issuer.serverId, unidentified.mapNotNull { it.entry() }).map { it.id }.toSet()
+ }
+ // Any server can claim any identity, so a pair two saved servers claim
+ // could be either one's: reading it from one could show the other's
+ // content and open the wrong server.
+ val installation = registered.singleOrNull {
+ it.identity() == issuer.serverId || (it.identity() == null && it.intent.serverId in answered)
+ } ?: return null
+
+ val owner = authorities.savedLoginAuthority(
+ installation.intent.serverId,
+ installation.intent.profileId,
+ installation.intent.profileToken,
+ )?.takeIf { it.loginId == installation.intent.loginId && it.scope.serverUrl == installation.serverUrl } ?: return null
+ return PushTarget(installation.intent.serverId, installation.intent.profileId, owner)
+ }
+}
diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/components/MediaRow.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/components/MediaRow.kt
index a5f912c78f..1446790716 100644
--- a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/components/MediaRow.kt
+++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/components/MediaRow.kt
@@ -36,6 +36,8 @@ import androidx.compose.ui.unit.sp
import coil3.imageLoader
import coil3.request.ImageRequest
import kotlin.math.roundToInt
+import org.koin.compose.koinInject
+import org.prairieserver.prairie.repository.ImageCapabilitiesSession
import org.prairieserver.prairie.common.ui.components.DeferImagePresentationWhileScrolling
import org.prairieserver.prairie.common.diagnostics.DiagnosticsKeyAnomalyLogger
import org.prairieserver.prairie.common.diagnostics.DiagnosticsKeyCollection
@@ -97,6 +99,7 @@ fun MediaRow(
onCenteredItemChanged: ((SectionItem?) -> Unit)? = null,
) {
val context = LocalContext.current
+ val imageCapabilities: ImageCapabilitiesSession = koinInject()
val heroHandoff = LocalHeroSourceHandoff.current
val uniqueItems = remember(items) { items.distinctBy { it.contentId } }
val diagnosticsKeySnapshot = remember(items) {
@@ -229,11 +232,14 @@ fun MediaRow(
onItemClick(item.contentId)
return
}
- // Keep one full-size backdrop request alive across the loading-
- // skeleton -> detail-content composition swap. Without this, the
- // skeleton's differently-sized request can be cancelled as soon as
- // metadata arrives and the real hero then starts from zero again.
- item.backdropUrl?.takeIf { it.isNotBlank() }?.let { backdropUrl ->
+ // A server that sizes backdrops per screen gives the detail its own
+ // hero URL, so the card's backdrop only stands in while it loads.
+ // Otherwise both use one URL: keep one full-size request alive
+ // across the loading-skeleton -> detail-content composition swap,
+ // or the skeleton's differently-sized request can be cancelled as
+ // soon as metadata arrives and the hero starts from zero again.
+ val sizedBackdrops = "backdrop" in imageCapabilities.capabilities.value?.acceptedWidthSlots.orEmpty()
+ item.backdropUrl?.takeIf { it.isNotBlank() && !sizedBackdrops }?.let { backdropUrl ->
val metrics = context.resources.displayMetrics
val widthPx = metrics.widthPixels.coerceAtLeast(1)
val heightPx = minOf(
diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/navigation/AppNavigation.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/navigation/AppNavigation.kt
index 0b7cdc0805..4dce7a15c1 100644
--- a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/navigation/AppNavigation.kt
+++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/navigation/AppNavigation.kt
@@ -114,7 +114,6 @@ import org.prairieserver.prairie.android.ui.screens.profiles.ProfileSelectionScr
import org.prairieserver.prairie.android.ui.screens.livetv.LiveTvPlayerScreen
import org.prairieserver.prairie.android.ui.screens.livetv.LiveTvScreen
import org.prairieserver.prairie.android.ui.screens.requests.MyRequestsScreen
-import org.prairieserver.prairie.android.ui.screens.requests.RequestApprovalsScreen
import org.prairieserver.prairie.android.ui.screens.requests.RequestDetailScreen
import org.prairieserver.prairie.android.ui.screens.requests.RequestsScreen
import org.prairieserver.prairie.android.ui.screens.search.MobileSearchMediaType
@@ -889,7 +888,9 @@ fun AppNavigation(
val switching = switchTo != null
// Same teardown as every other sign-out, which also has
// the next provider sign-in offer another account.
- if (signOutFirst) signOutTeardown.signOut(flushFirst = false, startSignIn = startSignIn)
+ // Nobody was signed out (another server or login became active
+ // meanwhile): don't open the sign-in this was preparing.
+ if (signOutFirst && !signOutTeardown.signOut(flushFirst = false, startSignIn = startSignIn)) return@launch
onRequeueExternalRoute(pairDeviceRoute(pendingCode ?: code))
navController.navigate(Route.Login.route) {
if (switching || signOutFirst) popUpTo(0) { inclusive = true }
@@ -1122,7 +1123,7 @@ fun AppNavigation(
onChangeServer = { navController.navigate(Route.ServerList.route) },
onSignOut = {
navScope.launch {
- signOutTeardown.signOut()
+ if (!signOutTeardown.signOut()) return@launch
navController.navigate(Route.Login.route) {
popUpTo(0) { inclusive = true }
}
@@ -1225,6 +1226,18 @@ fun AppNavigation(
}
}
}
+ // The request Approvals screen was removed (request administration is
+ // web admin only); a saved back stack from an older build can still
+ // hold its route, so keep a hidden redirect to Requests rather than
+ // crash on restore. Registered, never rendered.
+ composable("requests/approvals") {
+ LaunchedEffect(Unit) {
+ navController.navigate(Route.Requests.route) {
+ popUpTo("requests/approvals") { inclusive = true }
+ launchSingleTop = true
+ }
+ }
+ }
composable(Route.Settings.route) {
SettingsScreen(
onOpenInterface = { navController.navigate(Route.SettingsInterface.route) },
@@ -1390,21 +1403,12 @@ fun AppNavigation(
RequestsScreen(
onBackClick = { navController.popBackStack() },
onMyRequestsClick = { navController.navigate(Route.MyRequests.route) },
- onApprovalsClick = { navController.navigate(Route.RequestApprovals.route) },
onRequestDetailClick = openRequestDetail,
onLibraryItemClick = openRequestLibraryItem,
)
}
composable(Route.MyRequests.route) {
MyRequestsScreen(
- onBackClick = { navController.popBackStack() },
- onApprovalsClick = { navController.navigate(Route.RequestApprovals.route) },
- onRequestDetailClick = openRequestDetail,
- onLibraryItemClick = openRequestLibraryItem,
- )
- }
- composable(Route.RequestApprovals.route) {
- RequestApprovalsScreen(
onBackClick = { navController.popBackStack() },
onRequestDetailClick = openRequestDetail,
onLibraryItemClick = openRequestLibraryItem,
@@ -1533,13 +1537,16 @@ fun AppNavigation(
var resolvedSeriesId by rememberSaveable(initialContentId) { mutableStateOf(null) }
var resolvedSeason by rememberSaveable(initialContentId) { mutableStateOf(null) }
var resolvedEpisodeId by rememberSaveable(initialContentId) { mutableStateOf(null) }
- val resolvedContentId = resolvedSeriesId ?: initialContentId
+ // The reverse: a series opened for one of its episodes that cannot
+ // load shows that episode, which then stays put.
+ var fallbackEpisodeId by rememberSaveable(initialContentId) { mutableStateOf(null) }
+ val resolvedContentId = fallbackEpisodeId ?: resolvedSeriesId ?: initialContentId
SideEffect {
// A redirected episode now displays Series. External links
// must compare against this identity, not the original args.
backStackEntry.savedStateHandle[DisplayedDetailContentIdKey] = resolvedContentId
}
- val detailViewModel: ItemDetailViewModel = if (resolvedSeriesId == null) {
+ val detailViewModel: ItemDetailViewModel = if (resolvedContentId == initialContentId) {
koinViewModel()
} else {
koinViewModel(
@@ -1603,6 +1610,13 @@ fun AppNavigation(
resolvedEpisodeId = episodeId
resolvedSeriesId = seriesId
},
+ onEpisodeFallback = { episodeId ->
+ resolvedSeason = null
+ resolvedEpisodeId = null
+ resolvedSeriesId = null
+ fallbackEpisodeId = episodeId
+ },
+ allowSeriesRedirect = fallbackEpisodeId == null,
onShuffleStarted = { shuffle ->
navController.navigate(
org.prairieserver.prairie.android.ui.screens.shuffle.shufflePlayerRoute(shuffle, libraryId),
diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/navigation/Routes.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/navigation/Routes.kt
index b8e7376e74..1b6a1345da 100644
--- a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/navigation/Routes.kt
+++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/navigation/Routes.kt
@@ -139,7 +139,6 @@ sealed class Route(val route: String) {
// --- Requests ---
data object Requests : Route("requests")
data object MyRequests : Route("requests/mine")
- data object RequestApprovals : Route("requests/approvals")
data class RequestDetail(
val mediaType: String,
val tmdbId: Int,
diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/MainScreen.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/MainScreen.kt
index e349010c7b..42d0cebe19 100644
--- a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/MainScreen.kt
+++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/MainScreen.kt
@@ -308,7 +308,7 @@ fun MainScreen(
// Same teardown as the Settings sign-out: otherwise the next
// user's shell renders (and can write back) the previous
// profile's overlays and card presentation.
- signOutTeardown.signOut()
+ if (!signOutTeardown.signOut()) return@launch
navController.navigate(Route.Login.route) {
popUpTo(0) { inclusive = true }
launchSingleTop = true
diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/auth/ServerSetupScreen.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/auth/ServerSetupScreen.kt
index 30121c4227..12f8b9d4cd 100644
--- a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/auth/ServerSetupScreen.kt
+++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/auth/ServerSetupScreen.kt
@@ -27,6 +27,7 @@ import androidx.compose.material.icons.filled.KeyboardArrowDown
import androidx.compose.material.icons.filled.Link
import androidx.compose.material.icons.outlined.Language
import androidx.compose.material.icons.outlined.Settings
+import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@@ -56,6 +57,7 @@ import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
+import androidx.lifecycle.compose.LifecycleStartEffect
import org.koin.compose.koinInject
import org.koin.compose.viewmodel.koinViewModel
import org.prairieserver.prairie.android.ui.components.marquee.MarqueeButton
@@ -68,6 +70,8 @@ import org.prairieserver.prairie.android.ui.components.marquee.MarqueeStage
import org.prairieserver.prairie.android.ui.components.marquee.MarqueeTextField
import org.prairieserver.prairie.android.ui.components.marquee.MarqueeWordmark
import org.prairieserver.prairie.android.ui.components.marquee.marqueePressHaptic
+import org.prairieserver.prairie.common.discovery.DiscoveredServer
+import org.prairieserver.prairie.common.discovery.LanServerDiscovery
import org.prairieserver.prairie.common.ui.marquee.MarqueeColors
import org.prairieserver.prairie.common.ui.marquee.MarqueeScene
import org.prairieserver.prairie.common.ui.marquee.MarqueeScrimStyle
@@ -97,8 +101,15 @@ fun ServerSetupScreen(
viewModel: ServerSetupViewModel = koinViewModel(),
serverRegistry: ServerRegistry = koinInject(),
brandingCache: ServerBrandingCache = koinInject(),
+ discovery: LanServerDiscovery = koinInject(),
) {
val state by viewModel.uiState.collectAsState()
+ // Browse only while the screen is on: it stops in the background.
+ LifecycleStartEffect(discovery) {
+ discovery.start()
+ onStopOrDispose { discovery.stop() }
+ }
+ val found by discovery.servers.collectAsState()
val entries by serverRegistry.entries.collectAsState()
val recents = remember(entries) { entries.sortedByDescending { it.lastUsedAtEpochMs }.take(3) }
var fieldFocused by remember { mutableStateOf(false) }
@@ -130,6 +141,7 @@ fun ServerSetupScreen(
}
}
MarqueeErrorHaptic(state.error)
+ MarqueeErrorHaptic(state.discoveryError)
var showAdvanced by rememberSaveable { mutableStateOf(false) }
@@ -191,8 +203,31 @@ fun ServerSetupScreen(
modifier = Modifier.padding(top = 12.dp),
)
- // Recents are for picking before typing; while the address field has
- // the keyboard, the field and Continue need the room.
+ // Found servers and recents are for picking before typing; while the
+ // address field has the keyboard, the field and Continue need the room.
+ // The found list renders nothing until something is confirmed, so a
+ // network without servers (or without multicast) looks unchanged.
+ if (!fieldFocused) {
+ if (found.isNotEmpty()) {
+ Text(
+ "Found nearby",
+ color = MarqueeColors.InkSecondary,
+ fontSize = 15.sp,
+ fontWeight = FontWeight.SemiBold,
+ modifier = Modifier.padding(top = 22.dp, start = 2.dp, bottom = 10.dp),
+ )
+ FoundServers(
+ servers = found.take(4),
+ connectingUrl = state.connectingServerUrl.takeIf { state.isLoading },
+ enabled = !state.isLoading,
+ onPick = viewModel::connectTo,
+ )
+ }
+ state.discoveryError?.let { error ->
+ MarqueeErrorText(error, Modifier.padding(top = 10.dp, start = 4.dp))
+ }
+ }
+
if (recents.isNotEmpty() && !fieldFocused) {
Text(
"Recent",
@@ -353,6 +388,48 @@ private fun RecentServers(
enabled: Boolean,
onPick: (ServerEntry) -> Unit,
) {
+ ServerPickerList {
+ servers.forEachIndexed { index, server ->
+ if (index > 0) MarqueeSeparator()
+ ServerPickerRow(
+ name = server.displayName,
+ markName = server.fetchedName,
+ markUrl = brandingCache.branding(server.url)?.markUrl,
+ detail = ServerBranding.hostLabel(server.url),
+ enabled = enabled,
+ onClick = { onPick(server) },
+ )
+ }
+ }
+}
+
+/** Servers [LanServerDiscovery] found, in the Recent list's rows. */
+@Composable
+private fun FoundServers(
+ servers: List,
+ connectingUrl: String?,
+ enabled: Boolean,
+ onPick: (DiscoveredServer) -> Unit,
+) {
+ ServerPickerList {
+ servers.forEachIndexed { index, server ->
+ if (index > 0) MarqueeSeparator()
+ ServerPickerRow(
+ name = server.name,
+ markName = server.name,
+ markUrl = null,
+ detail = server.detail,
+ enabled = enabled,
+ isConnecting = server.url == connectingUrl,
+ onClick = { onPick(server) },
+ )
+ }
+ }
+}
+
+/** The rounded card of server rows used by Recent and the found servers. */
+@Composable
+private fun ServerPickerList(content: @Composable () -> Unit) {
val shape = RoundedCornerShape(16.dp)
Column(
Modifier
@@ -361,43 +438,56 @@ private fun RecentServers(
.background(Color.White.copy(alpha = 0.07f))
.border(1.dp, Color.White.copy(alpha = 0.12f), shape),
) {
- servers.forEachIndexed { index, server ->
- if (index > 0) MarqueeSeparator()
- val host = ServerBranding.hostLabel(server.url)
- val interaction = remember { MutableInteractionSource() }
- Row(
- Modifier
- .fillMaxWidth()
- .clickable(enabled = enabled, role = Role.Button, interactionSource = interaction, indication = null) { onPick(server) }
- .marqueePressHaptic(interaction)
- .semantics { contentDescription = "${server.displayName}, $host" }
- .padding(horizontal = 14.dp, vertical = 12.dp),
- horizontalArrangement = Arrangement.spacedBy(12.dp),
- verticalAlignment = Alignment.CenterVertically,
- ) {
- MarqueeServerMark(
- name = server.fetchedName,
- imageUrl = brandingCache.branding(server.url)?.markUrl,
- size = 40.dp,
- )
- Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) {
- Text(
- server.displayName,
- color = MarqueeColors.Ink,
- fontSize = 16.sp,
- fontWeight = FontWeight.SemiBold,
- maxLines = 1,
- overflow = TextOverflow.Ellipsis,
- )
- Text(host, color = MarqueeColors.InkTertiary, fontSize = 13.sp, maxLines = 1, overflow = TextOverflow.Ellipsis)
- }
- Icon(
- Icons.AutoMirrored.Filled.KeyboardArrowRight,
- contentDescription = null,
- tint = MarqueeColors.InkTertiary,
- modifier = Modifier.size(18.dp),
- )
+ content()
+ }
+}
+
+/** One server in a [ServerPickerList]: its mark, name and address. */
+@Composable
+private fun ServerPickerRow(
+ name: String,
+ markName: String?,
+ markUrl: String?,
+ detail: String,
+ enabled: Boolean,
+ onClick: () -> Unit,
+ isConnecting: Boolean = false,
+) {
+ val interaction = remember { MutableInteractionSource() }
+ Row(
+ Modifier
+ .fillMaxWidth()
+ .clickable(enabled = enabled, role = Role.Button, interactionSource = interaction, indication = null, onClick = onClick)
+ .marqueePressHaptic(interaction)
+ .semantics {
+ contentDescription = "$name, $detail"
+ if (isConnecting) stateDescription = "Connecting"
}
+ .padding(horizontal = 14.dp, vertical = 12.dp),
+ horizontalArrangement = Arrangement.spacedBy(12.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ MarqueeServerMark(name = markName, imageUrl = markUrl, size = 40.dp)
+ Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) {
+ Text(
+ name,
+ color = MarqueeColors.Ink,
+ fontSize = 16.sp,
+ fontWeight = FontWeight.SemiBold,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ )
+ Text(detail, color = MarqueeColors.InkTertiary, fontSize = 13.sp, maxLines = 1, overflow = TextOverflow.Ellipsis)
+ }
+ if (isConnecting) {
+ CircularProgressIndicator(color = MarqueeColors.Ink, strokeWidth = 2.dp, modifier = Modifier.size(18.dp))
+ } else {
+ Icon(
+ Icons.AutoMirrored.Filled.KeyboardArrowRight,
+ contentDescription = null,
+ tint = MarqueeColors.InkTertiary,
+ modifier = Modifier.size(18.dp),
+ )
}
}
}
diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/auth/ServerSetupViewModel.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/auth/ServerSetupViewModel.kt
index e46c4a8be0..18ddda20a5 100644
--- a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/auth/ServerSetupViewModel.kt
+++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/auth/ServerSetupViewModel.kt
@@ -2,6 +2,7 @@ package org.prairieserver.prairie.android.ui.screens.auth
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
+import org.prairieserver.prairie.common.discovery.DiscoveredServer
import org.prairieserver.prairie.common.network.CleartextConsentStore
import org.prairieserver.prairie.common.network.cleartextOrigin
import org.prairieserver.prairie.model.auth.SetupStatusResponse
@@ -28,6 +29,10 @@ data class ServerSetupUiState(
/** Destination after successful server validation. */
val navigateTo: ServerSetupDestination? = null,
val pendingCleartextUrl: String? = null,
+ /** The found server being connected to, so its row can show progress while [isLoading]. */
+ val connectingServerUrl: String? = null,
+ /** A failure connecting to a found server; shown beside the found list, not on the address field. */
+ val discoveryError: String? = null,
) {
/** True when the entered address will connect over unencrypted HTTP.
* Informational only โ does not block connecting to LAN/IP servers. */
@@ -99,7 +104,7 @@ class ServerSetupViewModel(
}
fun onServerUrlChanged(url: String) {
- _uiState.update { it.copy(serverUrl = url, error = null) }
+ _uiState.update { it.copy(serverUrl = url, error = null, discoveryError = null) }
}
fun onSchemeSelected(scheme: ServerSetupScheme) {
@@ -135,10 +140,30 @@ class ServerSetupViewModel(
return
}
+ connect(candidates, found = null)
+ }
+
+ /**
+ * Connects to a server found on the local network exactly as if its
+ * address had been typed: the same probes, and the same plain-HTTP
+ * consent before anything is saved.
+ */
+ fun connectTo(server: DiscoveredServer) {
+ if (_uiState.value.isLoading) return
+ connect(listOf(server.url), found = server)
+ }
+
+ private fun connect(candidates: List, found: DiscoveredServer?) {
viewModelScope.launch {
pendingCleartextConnection = null
_uiState.update {
- it.copy(isLoading = true, error = null, pendingCleartextUrl = null)
+ it.copy(
+ isLoading = true,
+ error = null,
+ discoveryError = null,
+ pendingCleartextUrl = null,
+ connectingServerUrl = found?.url,
+ )
}
var lastError: String? = null
@@ -147,9 +172,7 @@ class ServerSetupViewModel(
// server is reported as update-server, never retried on v1.
val probe = probeContract(candidate)
if (probe is ApiV2ProbeResult.UpdateServer) {
- _uiState.update {
- it.copy(isLoading = false, error = ServerContract.UPDATE_REQUIRED_MESSAGE)
- }
+ fail(ServerContract.UPDATE_REQUIRED_MESSAGE, found)
return@launch
}
// Anything else the probe said (V2, a transient failure, or a
@@ -189,14 +212,22 @@ class ServerSetupViewModel(
return@launch
}
- _uiState.update {
- it.copy(
- isLoading = false,
- error = lastError
- ?.takeIf { message -> message.isNotBlank() }
- ?.let { message -> "Could not connect: $message" }
- ?: "Could not reach a Prairie server at that address.",
- )
+ fail(
+ lastError
+ ?.takeIf { message -> message.isNotBlank() }
+ ?.let { message -> "Could not connect: $message" }
+ ?: "Could not reach a Prairie server at that address.",
+ found,
+ )
+ }
+ }
+
+ private fun fail(message: String, found: DiscoveredServer?) {
+ _uiState.update {
+ if (found == null) {
+ it.copy(isLoading = false, error = message)
+ } else {
+ it.copy(isLoading = false, discoveryError = message)
}
}
}
diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/detail/DetailLoadingSkeleton.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/detail/DetailLoadingSkeleton.kt
index ea3998e6f0..779bf3cb21 100644
--- a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/detail/DetailLoadingSkeleton.kt
+++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/detail/DetailLoadingSkeleton.kt
@@ -103,18 +103,27 @@ private fun TitlePlaceholder(shimmer: Float) {
Box(Modifier.fillMaxWidth(0.68f).height(38.dp).skeleton(shimmer))
}
+/** The facts row over the ratings row, spaced like [DetailHero]'s metadata column. */
@Composable
private fun MetadataPlaceholder(shimmer: Float) {
- Box(Modifier.fillMaxWidth(0.72f).height(20.dp).skeleton(shimmer))
+ Column(
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.spacedBy(10.dp),
+ ) {
+ Box(Modifier.fillMaxWidth(0.72f).height(22.dp).skeleton(shimmer))
+ Box(Modifier.fillMaxWidth(0.32f).height(20.dp).skeleton(shimmer))
+ }
}
+/** Play pill and the five equal-width circle columns of [HeroActionStack]. */
@Composable
private fun ActionPlaceholders(shimmer: Float) {
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
Box(Modifier.fillMaxWidth().height(52.dp).skeleton(shimmer, CircleShape))
- Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceEvenly) {
- repeat(4) {
+ Row(Modifier.fillMaxWidth()) {
+ repeat(5) {
Column(
+ modifier = Modifier.weight(1f),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(6.dp),
) {
diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/detail/DetailSharedComponents.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/detail/DetailSharedComponents.kt
index b983336a28..68e7f4a53b 100644
--- a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/detail/DetailSharedComponents.kt
+++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/detail/DetailSharedComponents.kt
@@ -66,10 +66,13 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.lerp
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.layout.ContentScale
+import androidx.compose.ui.layout.Layout
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.Constraints
+import androidx.compose.ui.unit.constrainHeight
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
@@ -200,6 +203,60 @@ fun AdaptiveDetailHero(
}
}
+/**
+ * The text and actions beside an expanded hero's portrait, in a column at
+ * least [minHeight] (the poster's height) tall: text at the top, actions at
+ * the bottom so Play ends level with the poster. When the text does not quite
+ * fit, the title gives up the difference (it is weighted in [text]) and the
+ * column stays the poster's height. When it would lose more than
+ * [TitleSqueezeAllowance], as with a request's Notify me line and status
+ * card, the column grows to fit the whole text and actions instead.
+ */
+@Composable
+private fun PosterAlignedHeroColumn(
+ minHeight: Dp,
+ gap: Dp,
+ modifier: Modifier = Modifier,
+ text: @Composable () -> Unit,
+ actions: @Composable () -> Unit,
+) {
+ Layout(
+ modifier = modifier,
+ content = {
+ text()
+ Column(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ ) {
+ actions()
+ }
+ },
+ ) { measurables, constraints ->
+ val width = constraints.maxWidth
+ val actionsPlaceable = measurables[1].measure(Constraints(maxWidth = width))
+ val besidePoster = (minHeight.roundToPx() - gap.roundToPx() - actionsPlaceable.height).coerceAtLeast(0)
+ val naturalText = measurables[0].maxIntrinsicHeight(width)
+ val textHeight = if (naturalText - besidePoster <= TitleSqueezeAllowance.roundToPx()) {
+ besidePoster
+ } else {
+ naturalText
+ }
+ val textPlaceable = measurables[0].measure(Constraints.fixed(width, textHeight))
+ val height = constraints.constrainHeight(
+ maxOf(minHeight.roundToPx(), textHeight + gap.roundToPx() + actionsPlaceable.height),
+ )
+ layout(width, height) {
+ textPlaceable.placeRelative(0, 0)
+ actionsPlaceable.placeRelative(
+ (width - actionsPlaceable.width) / 2,
+ height - actionsPlaceable.height,
+ )
+ }
+ }
+}
+
+private val TitleSqueezeAllowance = 48.dp
+
/**
* Expanded-window detail hero inspired by the reference foldable layout:
* artwork ends at the shared poster/action baseline, then fades into the
@@ -277,21 +334,15 @@ private fun ExpandedDetailHero(
}
}
- Column(
- modifier = Modifier
- .weight(1f)
- .then(if (hasPortrait) Modifier.height(posterHeight) else Modifier),
- horizontalAlignment = Alignment.CenterHorizontally,
- ) {
- // Beside a portrait, the text block takes only the height
- // the actions leave, so Play stays level with the poster.
- // Only the title gives up height: it is weighted, so the
- // facts and ratings rows are measured first and never clip,
- // and a logo scales down to what is left.
+ val textBlock: @Composable () -> Unit = {
+ // Beside a portrait only the title gives up height: it is
+ // weighted, so the facts and ratings rows are measured
+ // first and never clip, and a logo scales down to what is
+ // left.
Column(
modifier = Modifier
.fillMaxWidth()
- .then(if (hasPortrait) Modifier.weight(1f).clipToBounds() else Modifier),
+ .then(if (hasPortrait) Modifier.clipToBounds() else Modifier),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
@@ -314,11 +365,27 @@ private fun ExpandedDetailHero(
}
DetailRatingsRow(ratings = detail.titleRatings())
}
- Spacer(modifier = Modifier.height(if (hasPortrait) 12.dp else 20.dp))
+ }
+ if (hasPortrait) {
// Expanded/tablet only: Play and its bottom action row end
- // no lower than the portrait. The compact phone branch is
+ // level with the portrait. The compact phone branch is
// intentionally unchanged.
- actions()
+ PosterAlignedHeroColumn(
+ minHeight = posterHeight,
+ gap = 12.dp,
+ modifier = Modifier.weight(1f),
+ text = textBlock,
+ actions = actions,
+ )
+ } else {
+ Column(
+ modifier = Modifier.weight(1f),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ ) {
+ textBlock()
+ Spacer(modifier = Modifier.height(20.dp))
+ actions()
+ }
}
}
}
diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/detail/ItemDetailScreen.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/detail/ItemDetailScreen.kt
index def5f275bb..c41300d5df 100644
--- a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/detail/ItemDetailScreen.kt
+++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/detail/ItemDetailScreen.kt
@@ -133,6 +133,10 @@ fun ItemDetailScreen(
onPersonClick: (String) -> Unit,
onSeriesClick: (String) -> Unit,
onSeriesDetailReplace: (String, Int, String?) -> Unit,
+ /** A series opened for this episode cannot load; show the episode instead. */
+ onEpisodeFallback: (String) -> Unit = {},
+ /** False once this page fell back from its series, so it does not redirect there again. */
+ allowSeriesRedirect: Boolean = true,
onAudiobookPlayClick: (contentId: String, fileId: Int?, fromStart: Boolean, startPosition: Double?) -> Unit = { _, _, _, _ -> },
onBookReadClick: (String, Int?) -> Unit = { _, _ -> },
/** The route's library, staged with a Watch Party item. */
@@ -151,7 +155,9 @@ fun ItemDetailScreen(
) {
val state by viewModel.uiState.collectAsState()
val shuffleLauncher = org.prairieserver.prairie.android.ui.screens.shuffle.rememberShuffleLauncher(onShuffleStarted)
- val seriesRedirect = remember(state.detail) { state.detail?.let(::seriesDetailRedirect) }
+ val seriesRedirect = remember(state.detail, allowSeriesRedirect) {
+ state.detail?.takeIf { allowSeriesRedirect }?.let(::seriesDetailRedirect)
+ }
var seriesRedirectFailed by rememberSaveable(state.detail?.contentId) { mutableStateOf(false) }
LaunchedEffect(seriesRedirect) {
val redirect = seriesRedirect ?: return@LaunchedEffect
@@ -163,6 +169,9 @@ fun ItemDetailScreen(
seriesRedirectFailed = true
}
}
+ LaunchedEffect(state.episodeFallbackContentId) {
+ state.episodeFallbackContentId?.let(onEpisodeFallback)
+ }
val siloCastController: PrairieCastController = koinInject()
val siloCastState by siloCastController.state.collectAsState()
var showRemoteTargetPicker by remember { mutableStateOf(false) }
@@ -373,6 +382,8 @@ fun ItemDetailScreen(
// with the parent series. Hold the skeleton rather than dissolving
// into a season or episode detail that is on its way out.
seriesRedirect != null && !seriesRedirectFailed -> "loading"
+ // Likewise while a series that cannot load hands over to its episode.
+ state.episodeFallbackContentId != null -> "loading"
state.detail != null -> "detail"
state.error != null -> "error"
else -> "loading"
diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/detail/ItemDetailViewModel.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/detail/ItemDetailViewModel.kt
index 63545b6c68..d9c0506d7f 100644
--- a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/detail/ItemDetailViewModel.kt
+++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/detail/ItemDetailViewModel.kt
@@ -66,6 +66,13 @@ data class ItemDetailUiState(
val episodes: List = emptyList(),
/** Episode selected inside a series page. Its full detail powers the hero play target and selectors. */
val selectedEpisodeContentId: String? = null,
+ /**
+ * Set when a series opened for one of its episodes cannot load (refused,
+ * missing, or offline with nothing cached). The page shows that episode's
+ * own detail instead, as the episode-to-series redirect keeps the episode
+ * when its parent cannot open.
+ */
+ val episodeFallbackContentId: String? = null,
val selectedEpisodeDetail: ItemDetail? = null,
val isLoadingSelectedEpisodeDetail: Boolean = false,
/** Parent-series portrait art used when an episode's own artwork is a wide still. */
@@ -166,7 +173,10 @@ class ItemDetailViewModel(
savedStateHandle.get("episodeContentId")?.takeIf { it.isNotBlank() }
private var pendingInitialEpisodeContentId: String? = initialEpisodeContentId
- private val _uiState = MutableStateFlow(ItemDetailUiState())
+ // A route that names its episode starts with it selected, so the series
+ // hero opens on that episode instead of showing the series overview until
+ // the season's episodes arrive.
+ private val _uiState = MutableStateFlow(ItemDetailUiState(selectedEpisodeContentId = initialEpisodeContentId))
val uiState: StateFlow = kotlinx.coroutines.flow.combine(_uiState, personalDataRepository.memberships.actions) { state, actions ->
var projected = state
actions.values.filter { it.baseline != null && it.intent.key.itemId == contentId && personalDataRepository.memberships.current(it.intent) }.forEach {
@@ -174,7 +184,7 @@ class ItemDetailViewModel(
projected.copy(isFavorite = it.baseline!!.present) else projected.copy(isInWatchlist = it.baseline!!.present)
}
projected
- }.stateIn(viewModelScope, kotlinx.coroutines.flow.SharingStarted.Eagerly, ItemDetailUiState())
+ }.stateIn(viewModelScope, kotlinx.coroutines.flow.SharingStarted.Eagerly, _uiState.value)
private var episodeLoadJob: Job? = null
private var selectedEpisodeLoadJob: Job? = null
private var allEpisodeFileIdsJob: Job? = null
@@ -480,21 +490,25 @@ class ItemDetailViewModel(
}
is ApiResult.Error -> {
_uiState.update {
+ // The durable cache still holds a title the server
+ // now refuses; drop the copy seedCachedDetail painted
+ // so the screen shows the error, not stale actions.
+ val detail = if (dropsDetailOn(result)) null else it.detail
it.copy(
isLoading = false,
- // The durable cache still holds a title the server
- // now refuses; drop the copy seedCachedDetail painted
- // so the screen shows the error, not stale actions.
- detail = if (dropsDetailOn(result)) null else it.detail,
+ detail = detail,
error = result.message.ifBlank { "Failed to load details" },
+ episodeFallbackContentId = initialEpisodeContentId.takeIf { detail == null },
)
}
}
is ApiResult.NetworkError -> {
- _uiState.update {
- it.copy(
+ _uiState.update { state ->
+ state.copy(
isLoading = false,
error = "Network error. Check your connection.",
+ // Offline, the episode may still be cached when its series is not.
+ episodeFallbackContentId = initialEpisodeContentId.takeIf { state.detail == null },
)
}
}
@@ -981,10 +995,16 @@ class ItemDetailViewModel(
episodes.map { row -> current[row.contentId]?.let(row::withTranslatedDescription) ?: row }
} else episodes
val cache = it.episodesBySeason + (seasonNumber to rows)
+ // A route's episode is selected before its season loads, so
+ // its detail can arrive first. Give it the row's description,
+ // as a detail loaded after the rows gets.
+ val selectedRow = rows.firstOrNull { row -> row.contentId == it.selectedEpisodeContentId }
it.copy(
isLoadingEpisodes = false,
episodesBySeason = cache,
episodes = if (it.selectedSeasonNumber == seasonNumber) rows else it.episodes,
+ selectedEpisodeDetail = selectedRow?.let { row -> it.selectedEpisodeDetail?.withEpisodeDescription(row) }
+ ?: it.selectedEpisodeDetail,
)
}
ensureSeriesEpisodeSelection(episodes)
diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/home/HomeScreen.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/home/HomeScreen.kt
index 161f3f4e1a..eba2056fb6 100644
--- a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/home/HomeScreen.kt
+++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/home/HomeScreen.kt
@@ -41,6 +41,7 @@ import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.snapshotFlow
@@ -62,8 +63,11 @@ import org.prairieserver.prairie.android.ui.theme.prairiePageBackdrop
import org.prairieserver.prairie.android.ui.components.PrairieWordmark
import org.prairieserver.prairie.android.ui.components.TabTopBarActions
import org.prairieserver.prairie.android.ui.components.TopBarIconButton
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.CoroutineStart
import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope
+import kotlinx.coroutines.launch
import kotlinx.coroutines.flow.distinctUntilChanged
import org.prairieserver.prairie.android.ui.components.EmptyStateView
import org.prairieserver.prairie.android.ui.components.ErrorView
@@ -91,7 +95,7 @@ import org.prairieserver.prairie.repository.CatalogRepository
import org.koin.compose.koinInject
import org.koin.compose.viewmodel.koinViewModel
-private data class ContinueWatchingWarmTarget(
+private data class SeriesWarmTarget(
val seriesId: String,
val seasonNumber: Int?,
val episodeContentId: String,
@@ -159,12 +163,14 @@ fun HomeScreen(
val catalogLanguage: CatalogLanguageRevision = koinInject()
val languageRevision by catalogLanguage.revision.collectAsState()
val heroHandoff = LocalHeroSourceHandoff.current
- val continueWatchingWarmTargets = remember(regularSections) {
+ val tapWarmupScope = rememberCoroutineScope()
+ val seriesWarmTargets = remember(regularSections) {
regularSections
.asSequence()
.filter { section ->
section.sectionType == "continue_watching" ||
- section.sectionType == "in_progress"
+ section.sectionType == "in_progress" ||
+ section.sectionType == "next_up"
}
.flatMap { it.items.asSequence() }
.mapNotNull { item ->
@@ -172,7 +178,7 @@ fun HomeScreen(
if (!item.type.equals("episode", ignoreCase = true) || seriesId == null) {
null
} else {
- ContinueWatchingWarmTarget(
+ SeriesWarmTarget(
seriesId = seriesId,
seasonNumber = item.seasonNumber,
episodeContentId = item.contentId,
@@ -188,12 +194,12 @@ fun HomeScreen(
var warmedSeriesArtwork by remember(activeServerId, profileScopeId, languageRevision) {
mutableStateOf