chore(sync): merge Silo-Server/silo-android main (168 commits) - #27
Merged
Conversation
…r-sizing fix(android): size playback buffer from media bitrate
…nup-await test(tv): await structured subtitle cleanup deterministically
…er-user-menu-entry-design feat: add Watch Together to profile menus
…time depth/bytes guard Scoped re-review injected each wiring bug from the last fix and found "depth not routed into desiredForwardBufferMs" undetected: the 15% overhead margin calculateBitrateTargetBufferBytes applies makes a correct depth and an un-routed 180s depth overshoot the same budget and clamp to the identical byte ceiling, erasing the depth's effect from the only value a test could observe. Same root cause as two product gaps: the fixed 48/96/160 MiB budget tiers can hand a 96 MB-heap device half its heap as a buffer (OOM risk) while capping a 512 MB device's headroom unused, and the 20s floor silently overrides a smaller budget so currentDepthMs() could report 20s on a device the loader could actually only hold 6-7s on. - PlaybackBufferPolicy.memoryBudgetBytes is now 1/4 of the device's own heap (memoryClassMb), bounded to [16, 192] MiB, with a conservative fixed 24 MiB fallback for low-RAM or unknown-heap devices, replacing the fixed 48/96/160 MiB tiers. - affordableDepthMs now lets the budget win over minimumDepthMs when a known bitrate affords less than the floor (the floor remains a lower bound only for the unknown-bitrate branch, where there's no bitrate to derive a number from), and divides by the same 115/100 margin calculateBitrateTargetBufferBytes multiplies back in, so a budget-limited depth lands its byte target at or just under the budget instead of overshooting and clamping. - BufferSizingResult now wraps depth and bytes in BufferDepthMs/ BufferTargetBytes inline value classes so transposing them at the override's two-line handoff is a compile error, not a silent bug no test without Media3 scaffolding could catch. - Tests updated/added throughout: heap-proportional budget cases at both ends, the honest sub-floor depth value, and a computeBufferSizing case asserting the depth itself (not just the clamped byte target) for a budget-limited stream. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…peed-derived idle window, structural invariant
Final fix wave from whole-branch review ("No — with fixes"):
1. memoryBudgetBytes now takes half the heap, not a quarter. Measured via
adb: Shield memoryClass=192MB, Google TV Streamer memoryClass=384MB,
neither low-RAM. The old 25% rule gave both LESS buffer than they
shipped with before this branch existed; half gives Shield 96 MiB and
Streamer 192 MiB (at the cap), both at or above prior fixed values.
2. The low-RAM branch no longer ignores a known small memoryClass: it now
takes the smaller of the flat 24 MiB fallback and the proportional
share, falling back to the flat value only when memoryClassMb is
genuinely unknown (<= 0).
3. MAX_LOAD_IDLE_MS is now derived from the slowest selectable playback
rate (0.5x, shared by audiobooks): 15_000ms media time so the window
still fits the assumed 60s proxy send_timeout once stretched to wall
clock at 0.5x, where DefaultLoadControl does not scale minBufferUs.
4. PlaybackBufferPolicy's init now requires maxBufferMs - minBufferMs ==
MAX_LOAD_IDLE_MS, so the invariant can't be reintroduced by hand via the
public constructor or copy().
5. Removed a vacuous SiloLoadControlTest case that was algebraically true
regardless of what affordableDepthMs returned.
6. computeBufferSizing now coerces maximumBytes to be at least minimumBytes
before calling calculateBitrateTargetBufferBytes, so a future change to
either MIN_MEMORY_BUDGET_BYTES or MIN_TARGET_BUFFER_BYTES can't trigger
an IllegalArgumentException on the playback thread.
Verified: ./gradlew :android-shared:testDebugUnitTest (1,005 tests, 0
failures) and :androidApp:assembleDebug :androidTvApp:assembleDebug both
green.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
These eight commits were pushed straight to main, bypassing the review the branch protection asks for. The work itself is finished and reviewed, and is preserved on RXWatcher:feat/playback-buffer-architecture — it will land here through a pull request instead. Reverts a3cc647..d7ff760. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…e connection DefaultLoadControl stops reading the socket once the buffer hits maxBufferMs and does not resume until it drains below minBufferMs, so max - min is literally how long the connection sits idle. The old hardcoded 50s/120s pair left a 70s gap, well past a typical upstream proxy's 60s send_timeout, so long direct-play files got dropped every time the buffer filled. PlaybackBufferPolicy.forConditions() now derives maxBufferMs as minBufferMs + a fixed MAX_LOAD_IDLE_MS (30s), so depth can grow without ever widening the idle window. Depth is pinned to MAX_DEPTH_MS for now; a later task will shrink it per device memory budget without touching the idle-window guarantee. Deletes the unused PlaybackBufferMode enum (QuickStart/Balanced/ SmoothPlayback) and forMode(), which were never wired to any user or server setting.
Add affordableDepthMs, a pure helper beside the existing bitrate-selection and byte-target helpers in SiloLoadControl.kt. It computes the forward buffer depth a device's memory budget can actually fund at the selected bitrate, clamped between minimumDepthMs and the desired depth, so a high-bitrate stream's reduced depth is a number the code chose rather than wherever the byte clamp happens to truncate it. Not yet wired into calculateTargetBufferBytes — that's Task 3.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Task review found the Task 3 test exercised affordableDepthMs and calculateBitrateTargetBufferBytes directly (already covered by Task 2) rather than the calculateTargetBufferBytes wiring it claimed to test, so a wiring bug (wrong value into depthMs, fallback swapped for the budget as maximumBytes, or depth not routed into desiredForwardBufferMs) would still pass. Extract the composition into a pure computeBufferSizing helper beside the other internal helpers, reduce the override to a thin Media3-type adapter over it, and replace the test with cases against computeBufferSizing that pin the wiring itself. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…time depth/bytes guard Scoped re-review injected each wiring bug from the last fix and found "depth not routed into desiredForwardBufferMs" undetected: the 15% overhead margin calculateBitrateTargetBufferBytes applies makes a correct depth and an un-routed 180s depth overshoot the same budget and clamp to the identical byte ceiling, erasing the depth's effect from the only value a test could observe. Same root cause as two product gaps: the fixed 48/96/160 MiB budget tiers can hand a 96 MB-heap device half its heap as a buffer (OOM risk) while capping a 512 MB device's headroom unused, and the 20s floor silently overrides a smaller budget so currentDepthMs() could report 20s on a device the loader could actually only hold 6-7s on. - PlaybackBufferPolicy.memoryBudgetBytes is now 1/4 of the device's own heap (memoryClassMb), bounded to [16, 192] MiB, with a conservative fixed 24 MiB fallback for low-RAM or unknown-heap devices, replacing the fixed 48/96/160 MiB tiers. - affordableDepthMs now lets the budget win over minimumDepthMs when a known bitrate affords less than the floor (the floor remains a lower bound only for the unknown-bitrate branch, where there's no bitrate to derive a number from), and divides by the same 115/100 margin calculateBitrateTargetBufferBytes multiplies back in, so a budget-limited depth lands its byte target at or just under the budget instead of overshooting and clamping. - BufferSizingResult now wraps depth and bytes in BufferDepthMs/ BufferTargetBytes inline value classes so transposing them at the override's two-line handoff is a compile error, not a silent bug no test without Media3 scaffolding could catch. - Tests updated/added throughout: heap-proportional budget cases at both ends, the honest sub-floor depth value, and a computeBufferSizing case asserting the depth itself (not just the clamped byte target) for a budget-limited stream. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…peed-derived idle window, structural invariant
Final fix wave from whole-branch review ("No — with fixes"):
1. memoryBudgetBytes now takes half the heap, not a quarter. Measured via
adb: Shield memoryClass=192MB, Google TV Streamer memoryClass=384MB,
neither low-RAM. The old 25% rule gave both LESS buffer than they
shipped with before this branch existed; half gives Shield 96 MiB and
Streamer 192 MiB (at the cap), both at or above prior fixed values.
2. The low-RAM branch no longer ignores a known small memoryClass: it now
takes the smaller of the flat 24 MiB fallback and the proportional
share, falling back to the flat value only when memoryClassMb is
genuinely unknown (<= 0).
3. MAX_LOAD_IDLE_MS is now derived from the slowest selectable playback
rate (0.5x, shared by audiobooks): 15_000ms media time so the window
still fits the assumed 60s proxy send_timeout once stretched to wall
clock at 0.5x, where DefaultLoadControl does not scale minBufferUs.
4. PlaybackBufferPolicy's init now requires maxBufferMs - minBufferMs ==
MAX_LOAD_IDLE_MS, so the invariant can't be reintroduced by hand via the
public constructor or copy().
5. Removed a vacuous SiloLoadControlTest case that was algebraically true
regardless of what affordableDepthMs returned.
6. computeBufferSizing now coerces maximumBytes to be at least minimumBytes
before calling calculateBitrateTargetBufferBytes, so a future change to
either MIN_MEMORY_BUDGET_BYTES or MIN_TARGET_BUFFER_BYTES can't trigger
an IllegalArgumentException on the playback thread.
Verified: ./gradlew :android-shared:testDebugUnitTest (1,005 tests, 0
failures) and :androidApp:assembleDebug :androidTvApp:assembleDebug both
green.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…e byte floor calculateBitrateTargetBufferBytes requires maximumBytes >= minimumBytes. That relation was being restored by raising the ceiling to meet the floor, which inverts the intent: a device whose budget is under the nominal 16 MiB floor would be handed a byte target larger than the heap it was allowed. Restore it by lowering the floor instead, so the budget stays the binding limit on every path including the unknown-bitrate fallback. Unreachable on shipping hardware today — MIN_MEMORY_BUDGET_BYTES and MIN_TARGET_BUFFER_BYTES are both exactly 16 MiB — which is precisely why it needs a regression test rather than a comment. Also corrects doc drift: the spec and plan still described MAX_LOAD_IDLE_MS as 30_000 and the 20s depth floor as a guarantee. The constant became 15_000 (the 30s wall-clock budget divided by the 0.5x audiobook rate), and the floor is deliberately not a guarantee — a known bitrate whose budget funds less than 20s yields the honest smaller number, which is the whole point of making the reduction explicit. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The assertion was `stretchedWallClockMs <= ASSUMED_PROXY_SEND_TIMEOUT_MS`. Reverting MAX_LOAD_IDLE_MS to the pre-fix 30_000 yields 60_000 <= 60_000, so the one test whose job is to catch that revert passed through it — a guard that cannot fail is not a guard. Strict is also the correct statement of the property: a socket idle for exactly the timeout is a race, not a fit. Verified by temporarily setting the constant back to 30_000, where the test now fails. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…lan snippets Two things CodeRabbit caught on the re-review, both mine. The derivation was written backwards in prose: "a 30s wall-clock budget divided by the slowest playback rate" computes 60_000, not 15_000. The window is in media time and converts TO wall clock by dividing, so deriving the constant multiplies: 30_000 * 0.5 = 15_000. The shipped source comment had it right (30_000 * SLOWEST_PLAYBACK_SPEED); the spec, the plan, and the plan's own code comment did not. The plan also still carried the pre-fix affordableDepthMs: a test asserting `assertEquals(20_000, depth, "should clamp to the floor, not below it")` and an implementation using coerceIn(minimumDepthMs, ...) with no overhead margin. That is exactly the behaviour the review loop removed — anyone re-running the plan would have reimplemented the silent overrun. Both snippets now match the shipped code, including why the known-bitrate path is deliberately not floored. Docs only; no source change. Suite green. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(settings): send language tags, not display names
playback.audio_language and the profile's subtitle_language are BCP 47
language tags in the server's settings contract. The phone put the display
label on the wire verbatim — "English", not "en" — and the TV did the same
for audio while doing it correctly for subtitles.
That was already broken before the server started enforcing it: the same
string is handed to ExoPlayer as preferredAudioLanguage, and
setPreferredAudioLanguage("English") never matches a track tagged eng, so
choosing an audio language on Android has silently been a no-op. It also
meant Android and Apple wrote different vocabularies to the same key —
Apple has always sent codes, so a language picked on an iPhone read as
"Default" on the phone and vice versa.
Now that the server validates the tag, the flusher's PUT 400s and only
logs, so the setting would stop persisting entirely after a server upgrade.
Replaces the four drifted option lists with one table in shared, so a
language cannot be added to one surface and missed on the others, and
translates values already on devices on read rather than re-sending a
label the server will reject.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* feat(settings): adopt the generated contract bindings
SettingKeys.kt is generated from the server's manifest by cmd/settingsgen,
so this client cannot drift from the contract by editing a constant.
The two hand-maintained tables in AndroidPlayerSettingsStore now delegate to
it. BOOLEAN_KEYS/INT_KEYS/DOUBLE_KEYS was a second table that had to agree
with PlaybackSettingsKeys.DeviceSettings by discipline alone — a key added
to one and missed in the other flushes as the wrong type and is silently
dropped on read. Only the granular subtitle appearance fields stay local,
since the contract carries them as one composite object.
A new contract test caught two real drifts, both of which are the
disagreements the contract exists to end:
subtitle_appearance -> playback.subtitle_appearance. Every other key
carries a domain prefix; this one never did.
player.next_up_prompt_seconds -> playback.next_up_prompt_seconds. Android
shipped player.* while Apple and the server used playback.*, so the same
preference was two settings and neither client could read the other's.
Both are wire-format changes with no dual-write, which is what the
coordinated cutover is for.
Part of the cross-platform settings contract (Silo-Server/silo-server#479).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* feat(settings): add the canonical settings API client
The clients adopted the generated contract bindings but still speak only
the legacy string-only settings registry; nothing calls the canonical
/settings/contract or /settings/values routes. This adds that surface to
SettingsApi, mirroring the server's settings_values.go shapes exactly:
- getContractCapabilities() probes /settings/contract/capabilities and
returns a sealed SettingsCapabilitiesResult. A 404 means the server
predates the canonical API entirely, so it is a typed
ServerUpgradeRequired case the UI can present as "this server needs an
upgrade" instead of an empty settings screen.
- getEffectiveValues(keys, libraryIds, seriesIds) hits the batched
/settings/values/effective resolution: typed JSON values, the source
scope of each answer, constraint reporting (value vs stored_value),
and the contract revision the resolution was computed at.
- putValue/deleteValue address one scope explicitly via a validated
SettingScopeIdentity: scope + library_id/series_id ride the query, the
profile and device identity ride the session headers the auth
interceptor already attaches (with a setDeviceSetting-style optional
profileId override), matching how the server derives the identity so
one profile cannot write another's settings by naming it.
- Writes carry X-Silo-Mutation-Id from newSettingMutationId(): one UUID
per logical write, held across retries, so the flusher's retries
replay the recorded receipt instead of re-applying, and a reused id
with different content surfaces as 409 mutation_id_conflict.
Wire models live in SettingValueModels.kt beside the legacy models;
response scope/source fields stay raw strings so a server that adds a
scope cannot break deserialization. Shared unit tests cover the
serialization round-trips, the query/header/body encoding, and the
upgrade-required mapping for both routeless (plain-text) and JSON 404s.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(settings): flush and refresh device settings through the canonical API
ServerSettingsFlusher now writes to PUT/DELETE /api/v1/settings/values/{key}
at scope=profile_device with values encoded as the contract's JSON types
(classified by the generated SettingKeys.BOOLEAN_KEYS/INT_KEYS/DOUBLE_KEYS
sets; subtitle appearance goes up as its JSON object, and an empty language
tag as JSON null because the server's language_tag validator rejects "").
The 750ms debounce semantics are unchanged.
The old failure handling was a named defect: a failed PUT logged at Log.w
and dropped the write, so any server hiccup silently turned settings
non-persistent. Now a transient failure (network, 5xx, 408/429/401) keeps
the op queued and retries it with the SAME mutation id — minted once per
logical write via newSettingMutationId() — so the retry is an idempotent
replay the server can dedupe, first on a capped backoff and after that on
the next enqueue/flushNow trigger. Only a response that proves retrying is
pointless (contract rejection, mutation-id conflict) drops the op, and
every failure is logged at warning level with the key and status through
SiloLog. A delete answered 404 not_found is treated as already done.
Non-contract keys (the granular subtitle.* fields Android flattens out of
the composite appearance object) never reach the server, where they would
404 as unknown_setting.
AndroidPlayerSettingsStore.refreshFromServer() now hydrates from the
batched GET /settings/values/effective: typed JSON parsed per the generated
type sets, and a key nothing is stored for arrives as the contract default
with source "default" — so defaults come from the contract, never from a
hardcoded fallback, and a value reset from another device snaps back on
refresh. The subtitle device-override flag now derives from the resolved
scope (profile_device) instead of the legacy has_device_override field. A
key absent from the response means the server's contract predates it, so
the local value is kept. resetAllDeviceSettings deletes only server-stored
keys.
Part of the canonical settings API adoption; the API surface itself landed
in the previous commit.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(settings): write profile and quality preferences at canonical scopes
The subtitle triple (language, mode, forced) and the metadata language rode
named columns on PUT /profiles/{id}. The server still accepts them, but every
server-side reader resolves those preferences canonically from
user_setting_values, so the column write only takes effect via the mirror the
server keeps until cutover. Android now writes them itself, at scope=profile,
one key per edit — a failed write no longer reverts the other two, which is
what sending the whole triple every time did.
Reads come from the batched effective endpoint rather than the profile object,
so a value set on another device, or narrowed by policy, is what the screen
shows. Both apps go through one shared ProfileSettingsController: this repo's
history has the TV screen missing behaviors the phone has, and a behavior that
lives in one class cannot be present on one platform only.
Quality becomes the two axes the contract actually stores —
playback.preferred_quality (resolution) and playback.max_bitrate_kbps
(bandwidth, null = uncapped) — behind one preset picker whose table is a port
of the web client's qualityPresets.ts. Presets stay client-side on purpose:
retuning what "1080p High" means is a client release, not a contract break.
The compound legacy spellings ("1080p-high") are dead and never written; a
stored one is decomposed on read, dropping the bitrate it encoded rather than
inventing a cap the user never chose.
Subtitle appearance keeps its granular subtitle.* fields client-local (the
contract carries one composite object and would refuse them as unknown_setting)
but they are no longer stranded there: they project into
playback.subtitle_appearance on flush, so a per-field edit reaches the server,
and a resolved appearance flattens back into them so the overlay cannot
resurrect the value the server just replaced.
A server that predates the canonical settings API 404s the contract probe.
Both settings screens now say so instead of rendering rows whose edits
silently go nowhere; playback keeps working from the device-scoped defaults.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* test(settings): add the cross-platform conformance runner
The settings contract names four resolvers that must agree: Go in
internal/settingsresolve, TypeScript in web/src/lib, Swift in the Apple
clients, and Kotlin here. Three of them ran the shared conformance
fixture; Kotlin did not, so nothing caught this client resolving a
setting differently from the server until a user saw the wrong value.
Vendors contracts/settings/v1/conformance.json byte-identically, plus
the manifest it was authored against. The manifest is needed because the
generated SettingKeys bindings carry key names and a coarse type table
but not the facts resolution turns on — resolution_order, default_value,
enum member order with its `ordered` flag, and constrained_by. Copying
those into Kotlin by hand would recreate exactly the drift the contract
exists to remove, so the runner parses the manifest and is driven by it.
No generator change is required.
The resolver lives in test sources on purpose. Android does not resolve
settings in production: it writes through /settings/values and reads
effective values back, leaving the server the single authority. This
exists so the fixture has a fourth independent implementation to
disagree with, which is what makes it a drift gate rather than a
tautology.
Four things fail the suite, each of them drift: a resolution
disagreement, a revision mismatch across the fixture / vendored manifest
/ generated bindings, a key those two JSON files disagree about (which
catches them being vendored from different server commits — skew the
revision check cannot see), and any fixture field the runner does not
recognize. The last one is why decoding is strict: a field one platform
reads and another silently skips means the platforms have stopped
running the same cases, and a silent skip is indistinguishable from a
pass.
Verified by mutating the resolver and confirming the suite fails:
reversed resolution order, a null bitrate slipping past a ceiling, a
floor capping an unbounded value, allowlist falling back to the
definition default, locked narrowing an already-equal value, ordered
enum ranking disabled, and foreign-profile rows resolving. Each gate was
mutation-tested too. One mutation survives — dropping the non-empty
device-id guard — because no fixture case makes it load-bearing in any
language; that gap is documented at the guard and is fixed upstream in
the fixture, not here, so all four runners gain the case together.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(settings): review pass over the canonical API adoption
Six defects found reviewing the canonical settings adoption, five of which
lose or misreport a user's setting.
A transiently-failed flusher op was re-queued even after a newer value for
the same key was drained and sent in the same flush. `retryable` was
add-only, so a later drain pass that landed a newer op left the older
failed entry behind, and the post-loop `composite !in pending` guard could
not compensate — the pass that sent the newer op had already cleared
`pending`. `scheduleRetry` then replayed the superseded value with its
original mutation id, which the server's first-use-id path does not dedupe,
overwriting the edit the user had just made. Reachable from every
`flushNow()` caller (activity onStop, logout, the device-setting resets),
where a concurrent enqueue is not cancelled. Dropping the composite from
`retryable` on success keeps only the latest failed state per key.
The phone playback starter still read `user_profiles.subtitle_language`.
The settings screens write these preferences at `scope=profile` now, and
nothing on the server mirrors a canonical write back into that column, so
the phone auto-selected subtitles from the pre-edit value while Android TV
— which reads WatchDetail's server-resolved `effective_*` fields — played
the new one. Same intent, same server, different playback per platform.
The phone starter now prefers `effective_*` the way the TV starter does,
and passes the mode and forced-subtitle flag it previously dropped.
The TV detail page's "Auto" subtitle preview had the same stale source:
it advertised the pre-edit preference while starting playback from that
same row used the canonical one. It resolves through
ProfileSettingsController now, translating the snapshot's "" (no
preference) into the preview's null so an unset language does not read as
"no subtitles".
A 404 on the capabilities probe was read as "server too old". That route
sits behind the viewer-access middleware, which answers a JSON
`{"error":"not_found"}` when the X-Profile-Id we send names a profile the
household deleted elsewhere — so a current server told users to go ask
their admin for an upgrade when the fix was re-selecting a profile. A
genuinely old server has no `/settings/contract` routes and gets chi's
plain-text 404, which parses to an empty error code, so gating on that
separates the two.
The TV legacy-prefs import wrote only the resolution axis, leaving a
(resolution, no bitrate) pair no picker preset covers: the row read
"720p" but the picker showed nothing selected with the cursor on Auto, and
the sentinel is marked on the same pass so it could never be re-migrated.
It now writes both axes at the bitrates the server's own migration assigns
the same legacy values.
The only test for the subtitle-appearance projection passed with the whole
feature reverted — it asserted a negative that any no-op satisfies. It now
writes a granular slot through the legacy-import path (the genuinely
unguarded one) and asserts the flush carries it, with a second test for the
read overlay and the redundant-write guard kept separately. Verified by
mutation: deleting either half of the projection now fails.
Every fix is pinned by a test that fails without it, checked by reverting
each change in turn. Full suites green: 2996 tests across shared,
android-shared, androidApp and androidTvApp.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(settings): address PR Silo-Server#119 review findings
Eight findings from the Codex and CodeRabbit passes over the canonical
settings adoption, six of which lose or misreport a user's setting.
The settings cutover renamed two keys (subtitle_appearance ->
playback.subtitle_appearance, player.next_up_prompt_seconds ->
playback.next_up_prompt_seconds). That is a contract question for the
server, but on disk it orphans values an installed build already wrote.
Both keys read local-first — subtitle appearance drives downloaded
playback with no server in the loop, next-up prompt falls back to its 30s
default — so an upgrade silently reverted both. PlaybackSettingsKeys
carries the rename table now and the store copies each slot forward once,
under its own sentinel: the existing one is already marked on every device
that has run a scoped build, so a pass gated on it would never run for the
installs actually holding the orphans. A value already under the new name
always wins.
A queued flusher op outlived a server switch. The flusher is
application-scoped and SettingsApi requests are relative, so a retained
retry addressed whichever server was active when it was finally sent —
and a restored or cloned server recognizing the same profile id would
accept it. Ops carry the server they were authored against and are
dropped, not deferred, once that origin is no longer active.
The bandwidth half of the quality choice never reached playback. The
server applies the cap only from the request's bandwidth_cap_kbps and
nothing on the playback path reads the stored setting, so "1080p Low"
streamed at whatever bitrate the ladder picked. Both starters send it now
and the attempt carries it, so replans re-send it rather than silently
lifting the limit mid-session.
A successful PUT stores the authored value; it does not make it effective.
Policy can narrow a setting and a profile_device row outranks the profile
row these setters write, so both screens could show a preference playback
was not using. ProfileSettingsController re-resolves after each successful
write and returns what the server actually holds; a failed re-resolve
keeps the optimistic value rather than rolling back a change that landed.
The TV legacy import guarded only the resolution axis while setQuality
writes both, so a device with a server-side bitrate cap and no resolution
override had that cap overwritten by the legacy preset's bitrate — or by
JSON null for a legacy Auto. Both axes are queried and guarded.
Blank effective_* strings reached subtitle auto-selection as a real
preference. A canonical row holding JSON null unmarshals to "" server-side
and arrives present-but-empty, which both auto-selectors read as an
explicit "subtitles off" — turning subtitles off for users who never chose
a language. Normalized on every rung, matching the audio path.
Metadata language rendered its unset value as "Off" on both platforms,
though it means "inherit the library's language" rather than disabling
anything.
Verification: :shared, :android-shared, :androidApp and :androidTvApp unit
tests plus both app compiles, --rerun-tasks to defeat stale caches —
3173 tests, 0 failures.
Not reproduced: CodeRabbit flagged AndroidPlayerSettingsStoreTest:535-543
as a critical compile failure on a nullable smart cast. kotlin.test
.assertTrue declares a returns()-implies contract, so the cast holds; the
file compiles clean under --rerun-tasks.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(settings): send language tags, not display names
playback.audio_language and the profile's subtitle_language are BCP 47
language tags in the server's settings contract. The phone put the display
label on the wire verbatim — "English", not "en" — and the TV did the same
for audio while doing it correctly for subtitles.
That was already broken before the server started enforcing it: the same
string is handed to ExoPlayer as preferredAudioLanguage, and
setPreferredAudioLanguage("English") never matches a track tagged eng, so
choosing an audio language on Android has silently been a no-op. It also
meant Android and Apple wrote different vocabularies to the same key —
Apple has always sent codes, so a language picked on an iPhone read as
"Default" on the phone and vice versa.
Now that the server validates the tag, the flusher's PUT 400s and only
logs, so the setting would stop persisting entirely after a server upgrade.
Replaces the four drifted option lists with one table in shared, so a
language cannot be added to one surface and missed on the others, and
translates values already on devices on read rather than re-sending a
label the server will reject.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* feat(settings): adopt the generated contract bindings
SettingKeys.kt is generated from the server's manifest by cmd/settingsgen,
so this client cannot drift from the contract by editing a constant.
The two hand-maintained tables in AndroidPlayerSettingsStore now delegate to
it. BOOLEAN_KEYS/INT_KEYS/DOUBLE_KEYS was a second table that had to agree
with PlaybackSettingsKeys.DeviceSettings by discipline alone — a key added
to one and missed in the other flushes as the wrong type and is silently
dropped on read. Only the granular subtitle appearance fields stay local,
since the contract carries them as one composite object.
A new contract test caught two real drifts, both of which are the
disagreements the contract exists to end:
subtitle_appearance -> playback.subtitle_appearance. Every other key
carries a domain prefix; this one never did.
player.next_up_prompt_seconds -> playback.next_up_prompt_seconds. Android
shipped player.* while Apple and the server used playback.*, so the same
preference was two settings and neither client could read the other's.
Both are wire-format changes with no dual-write, which is what the
coordinated cutover is for.
Part of the cross-platform settings contract (Silo-Server/silo-server#479).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* feat(settings): add the canonical settings API client
The clients adopted the generated contract bindings but still speak only
the legacy string-only settings registry; nothing calls the canonical
/settings/contract or /settings/values routes. This adds that surface to
SettingsApi, mirroring the server's settings_values.go shapes exactly:
- getContractCapabilities() probes /settings/contract/capabilities and
returns a sealed SettingsCapabilitiesResult. A 404 means the server
predates the canonical API entirely, so it is a typed
ServerUpgradeRequired case the UI can present as "this server needs an
upgrade" instead of an empty settings screen.
- getEffectiveValues(keys, libraryIds, seriesIds) hits the batched
/settings/values/effective resolution: typed JSON values, the source
scope of each answer, constraint reporting (value vs stored_value),
and the contract revision the resolution was computed at.
- putValue/deleteValue address one scope explicitly via a validated
SettingScopeIdentity: scope + library_id/series_id ride the query, the
profile and device identity ride the session headers the auth
interceptor already attaches (with a setDeviceSetting-style optional
profileId override), matching how the server derives the identity so
one profile cannot write another's settings by naming it.
- Writes carry X-Silo-Mutation-Id from newSettingMutationId(): one UUID
per logical write, held across retries, so the flusher's retries
replay the recorded receipt instead of re-applying, and a reused id
with different content surfaces as 409 mutation_id_conflict.
Wire models live in SettingValueModels.kt beside the legacy models;
response scope/source fields stay raw strings so a server that adds a
scope cannot break deserialization. Shared unit tests cover the
serialization round-trips, the query/header/body encoding, and the
upgrade-required mapping for both routeless (plain-text) and JSON 404s.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(settings): flush and refresh device settings through the canonical API
ServerSettingsFlusher now writes to PUT/DELETE /api/v1/settings/values/{key}
at scope=profile_device with values encoded as the contract's JSON types
(classified by the generated SettingKeys.BOOLEAN_KEYS/INT_KEYS/DOUBLE_KEYS
sets; subtitle appearance goes up as its JSON object, and an empty language
tag as JSON null because the server's language_tag validator rejects "").
The 750ms debounce semantics are unchanged.
The old failure handling was a named defect: a failed PUT logged at Log.w
and dropped the write, so any server hiccup silently turned settings
non-persistent. Now a transient failure (network, 5xx, 408/429/401) keeps
the op queued and retries it with the SAME mutation id — minted once per
logical write via newSettingMutationId() — so the retry is an idempotent
replay the server can dedupe, first on a capped backoff and after that on
the next enqueue/flushNow trigger. Only a response that proves retrying is
pointless (contract rejection, mutation-id conflict) drops the op, and
every failure is logged at warning level with the key and status through
SiloLog. A delete answered 404 not_found is treated as already done.
Non-contract keys (the granular subtitle.* fields Android flattens out of
the composite appearance object) never reach the server, where they would
404 as unknown_setting.
AndroidPlayerSettingsStore.refreshFromServer() now hydrates from the
batched GET /settings/values/effective: typed JSON parsed per the generated
type sets, and a key nothing is stored for arrives as the contract default
with source "default" — so defaults come from the contract, never from a
hardcoded fallback, and a value reset from another device snaps back on
refresh. The subtitle device-override flag now derives from the resolved
scope (profile_device) instead of the legacy has_device_override field. A
key absent from the response means the server's contract predates it, so
the local value is kept. resetAllDeviceSettings deletes only server-stored
keys.
Part of the canonical settings API adoption; the API surface itself landed
in the previous commit.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(settings): write profile and quality preferences at canonical scopes
The subtitle triple (language, mode, forced) and the metadata language rode
named columns on PUT /profiles/{id}. The server still accepts them, but every
server-side reader resolves those preferences canonically from
user_setting_values, so the column write only takes effect via the mirror the
server keeps until cutover. Android now writes them itself, at scope=profile,
one key per edit — a failed write no longer reverts the other two, which is
what sending the whole triple every time did.
Reads come from the batched effective endpoint rather than the profile object,
so a value set on another device, or narrowed by policy, is what the screen
shows. Both apps go through one shared ProfileSettingsController: this repo's
history has the TV screen missing behaviors the phone has, and a behavior that
lives in one class cannot be present on one platform only.
Quality becomes the two axes the contract actually stores —
playback.preferred_quality (resolution) and playback.max_bitrate_kbps
(bandwidth, null = uncapped) — behind one preset picker whose table is a port
of the web client's qualityPresets.ts. Presets stay client-side on purpose:
retuning what "1080p High" means is a client release, not a contract break.
The compound legacy spellings ("1080p-high") are dead and never written; a
stored one is decomposed on read, dropping the bitrate it encoded rather than
inventing a cap the user never chose.
Subtitle appearance keeps its granular subtitle.* fields client-local (the
contract carries one composite object and would refuse them as unknown_setting)
but they are no longer stranded there: they project into
playback.subtitle_appearance on flush, so a per-field edit reaches the server,
and a resolved appearance flattens back into them so the overlay cannot
resurrect the value the server just replaced.
A server that predates the canonical settings API 404s the contract probe.
Both settings screens now say so instead of rendering rows whose edits
silently go nowhere; playback keeps working from the device-scoped defaults.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* test(settings): add the cross-platform conformance runner
The settings contract names four resolvers that must agree: Go in
internal/settingsresolve, TypeScript in web/src/lib, Swift in the Apple
clients, and Kotlin here. Three of them ran the shared conformance
fixture; Kotlin did not, so nothing caught this client resolving a
setting differently from the server until a user saw the wrong value.
Vendors contracts/settings/v1/conformance.json byte-identically, plus
the manifest it was authored against. The manifest is needed because the
generated SettingKeys bindings carry key names and a coarse type table
but not the facts resolution turns on — resolution_order, default_value,
enum member order with its `ordered` flag, and constrained_by. Copying
those into Kotlin by hand would recreate exactly the drift the contract
exists to remove, so the runner parses the manifest and is driven by it.
No generator change is required.
The resolver lives in test sources on purpose. Android does not resolve
settings in production: it writes through /settings/values and reads
effective values back, leaving the server the single authority. This
exists so the fixture has a fourth independent implementation to
disagree with, which is what makes it a drift gate rather than a
tautology.
Four things fail the suite, each of them drift: a resolution
disagreement, a revision mismatch across the fixture / vendored manifest
/ generated bindings, a key those two JSON files disagree about (which
catches them being vendored from different server commits — skew the
revision check cannot see), and any fixture field the runner does not
recognize. The last one is why decoding is strict: a field one platform
reads and another silently skips means the platforms have stopped
running the same cases, and a silent skip is indistinguishable from a
pass.
Verified by mutating the resolver and confirming the suite fails:
reversed resolution order, a null bitrate slipping past a ceiling, a
floor capping an unbounded value, allowlist falling back to the
definition default, locked narrowing an already-equal value, ordered
enum ranking disabled, and foreign-profile rows resolving. Each gate was
mutation-tested too. One mutation survives — dropping the non-empty
device-id guard — because no fixture case makes it load-bearing in any
language; that gap is documented at the guard and is fixed upstream in
the fixture, not here, so all four runners gain the case together.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(settings): review pass over the canonical API adoption
Six defects found reviewing the canonical settings adoption, five of which
lose or misreport a user's setting.
A transiently-failed flusher op was re-queued even after a newer value for
the same key was drained and sent in the same flush. `retryable` was
add-only, so a later drain pass that landed a newer op left the older
failed entry behind, and the post-loop `composite !in pending` guard could
not compensate — the pass that sent the newer op had already cleared
`pending`. `scheduleRetry` then replayed the superseded value with its
original mutation id, which the server's first-use-id path does not dedupe,
overwriting the edit the user had just made. Reachable from every
`flushNow()` caller (activity onStop, logout, the device-setting resets),
where a concurrent enqueue is not cancelled. Dropping the composite from
`retryable` on success keeps only the latest failed state per key.
The phone playback starter still read `user_profiles.subtitle_language`.
The settings screens write these preferences at `scope=profile` now, and
nothing on the server mirrors a canonical write back into that column, so
the phone auto-selected subtitles from the pre-edit value while Android TV
— which reads WatchDetail's server-resolved `effective_*` fields — played
the new one. Same intent, same server, different playback per platform.
The phone starter now prefers `effective_*` the way the TV starter does,
and passes the mode and forced-subtitle flag it previously dropped.
The TV detail page's "Auto" subtitle preview had the same stale source:
it advertised the pre-edit preference while starting playback from that
same row used the canonical one. It resolves through
ProfileSettingsController now, translating the snapshot's "" (no
preference) into the preview's null so an unset language does not read as
"no subtitles".
A 404 on the capabilities probe was read as "server too old". That route
sits behind the viewer-access middleware, which answers a JSON
`{"error":"not_found"}` when the X-Profile-Id we send names a profile the
household deleted elsewhere — so a current server told users to go ask
their admin for an upgrade when the fix was re-selecting a profile. A
genuinely old server has no `/settings/contract` routes and gets chi's
plain-text 404, which parses to an empty error code, so gating on that
separates the two.
The TV legacy-prefs import wrote only the resolution axis, leaving a
(resolution, no bitrate) pair no picker preset covers: the row read
"720p" but the picker showed nothing selected with the cursor on Auto, and
the sentinel is marked on the same pass so it could never be re-migrated.
It now writes both axes at the bitrates the server's own migration assigns
the same legacy values.
The only test for the subtitle-appearance projection passed with the whole
feature reverted — it asserted a negative that any no-op satisfies. It now
writes a granular slot through the legacy-import path (the genuinely
unguarded one) and asserts the flush carries it, with a second test for the
read overlay and the redundant-write guard kept separately. Verified by
mutation: deleting either half of the projection now fails.
Every fix is pinned by a test that fails without it, checked by reverting
each change in turn. Full suites green: 2996 tests across shared,
android-shared, androidApp and androidTvApp.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(settings): address PR Silo-Server#119 review findings
Eight findings from the Codex and CodeRabbit passes over the canonical
settings adoption, six of which lose or misreport a user's setting.
The settings cutover renamed two keys (subtitle_appearance ->
playback.subtitle_appearance, player.next_up_prompt_seconds ->
playback.next_up_prompt_seconds). That is a contract question for the
server, but on disk it orphans values an installed build already wrote.
Both keys read local-first — subtitle appearance drives downloaded
playback with no server in the loop, next-up prompt falls back to its 30s
default — so an upgrade silently reverted both. PlaybackSettingsKeys
carries the rename table now and the store copies each slot forward once,
under its own sentinel: the existing one is already marked on every device
that has run a scoped build, so a pass gated on it would never run for the
installs actually holding the orphans. A value already under the new name
always wins.
A queued flusher op outlived a server switch. The flusher is
application-scoped and SettingsApi requests are relative, so a retained
retry addressed whichever server was active when it was finally sent —
and a restored or cloned server recognizing the same profile id would
accept it. Ops carry the server they were authored against and are
dropped, not deferred, once that origin is no longer active.
The bandwidth half of the quality choice never reached playback. The
server applies the cap only from the request's bandwidth_cap_kbps and
nothing on the playback path reads the stored setting, so "1080p Low"
streamed at whatever bitrate the ladder picked. Both starters send it now
and the attempt carries it, so replans re-send it rather than silently
lifting the limit mid-session.
A successful PUT stores the authored value; it does not make it effective.
Policy can narrow a setting and a profile_device row outranks the profile
row these setters write, so both screens could show a preference playback
was not using. ProfileSettingsController re-resolves after each successful
write and returns what the server actually holds; a failed re-resolve
keeps the optimistic value rather than rolling back a change that landed.
The TV legacy import guarded only the resolution axis while setQuality
writes both, so a device with a server-side bitrate cap and no resolution
override had that cap overwritten by the legacy preset's bitrate — or by
JSON null for a legacy Auto. Both axes are queried and guarded.
Blank effective_* strings reached subtitle auto-selection as a real
preference. A canonical row holding JSON null unmarshals to "" server-side
and arrives present-but-empty, which both auto-selectors read as an
explicit "subtitles off" — turning subtitles off for users who never chose
a language. Normalized on every rung, matching the audio path.
Metadata language rendered its unset value as "Off" on both platforms,
though it means "inherit the library's language" rather than disabling
anything.
Verification: :shared, :android-shared, :androidApp and :androidTvApp unit
tests plus both app compiles, --rerun-tasks to defeat stale caches —
3173 tests, 0 failures.
Not reproduced: CodeRabbit flagged AndroidPlayerSettingsStoreTest:535-543
as a critical compile failure on a nullable smart cast. kotlin.test
.assertTrue declares a returns()-implies contract, so the cast holds; the
file compiles clean under --rerun-tasks.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(android): resolve settings end-to-end defects
* fix(android): address review feedback
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
…t-season-order fix(android): place Specials before numbered seasons
…log-viewport fix(tv): keep option dialogs inside the viewport
fix(playback): bound the load-idle window so proxies stop dropping stream connections
…ar (Silo-Server#156) * fix(tv): keep the For You list selection, scroll, and a legible top bar Reported against v1.0.0+3 (83e23da), which already contains the recent TV focus batch — these are live on current code, not stale-release artifacts. **Watchlist and Favorites reverted to For You after opening an item.** `savedListSelection` was a plain `remember`, so opening an item disposed the composition and the value re-initialised from `entryRequest.selection` on the way back. Top-level For You entry carries `selection = null`, so returning from a Watchlist item did not merely forget the list — it actively reselected the recommendations feed. `lastAppliedEntrySequence` had to move with it. Left as `remember` it resets to 0, which makes the entry-request effect treat the unchanged request as new and re-apply its selection — reintroducing the same jump even once the selection itself is saved. Both are now `rememberSaveable`. **Scroll position was lost returning to the feed.** The recommendations `LazyColumn` created its list state inside the `when` branch, so a refresh that briefly flipped to loading/empty and back discarded it. Hoisted above the branch. **The top bar was unreadable over For You.** `TvTopMenuBar` deliberately has no background band of its own and documents that "the SHELL draws a fixed top scrim behind the bar" (QA 2026-07-08) — but the shell drew none, so the labels sat directly on whatever scrolled underneath. On For You that is a poster row. Restored as a gradient rather than a solid band, which satisfies both that contract and the shell's own intent that content stay visible behind the bar. This fixes every route, not just For You. Not addressed: the jerky scrolling on For You, and "cannot scroll the sections" after returning. The latter looks like focus restoration rather than scroll state (on a D-pad, no focus means no scrolling) and belongs with TvRecommendationsFocusBridge; both want their own change. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(tv): hand focus back into For You after returning from an item Completes the second half of the report: "I can't scroll the For You sections anymore" after opening an item and coming back. The shell already has a detail-return path — a flag set when opening, a resume claim on the content group, and a restorer fallback pointing at the launch card — but every part of it was Home-only, and For You was wired to the raw onOpenItemDetail. Nothing claimed content focus on the way back, so focus settled wherever Compose's default search landed, and the D-pad no longer drove the rows the viewer was just in. Two flags now, deliberately: `restoreContentAfterDetail` means a return is pending for ANY root and gates the resume claim; `restoreHomeContentAfterDetail` additionally means it was the Home feed, which is the only root that attaches homeDetailReturnCardFocusRequester to its launch card. Using that requester as the restorer fallback for a root that never attached it would aim the restorer at a detached node, so Home's behaviour is left byte-identical and For You opts into the claim alone. The screen's own once-per-entry focus grab had to stop fighting it. Its guards were plain `remember`, so a detail return reset them, re-fired the effect and slammed focus onto the Watchlist pill while the feed sat scrolled where the viewer left it — the exact anti-pattern TvMainShell warns about: "fired LaunchedEffects in each screen that imperatively re-focused index 0 — defeating the restorer". Saved, the grab stays genuinely once-per-entry. Jerky scrolling is NOT addressed. The usual causes are ruled out — TvMediaRow carries key and contentType, hoists its row state, and memoises its item mapping on remember(items, showProgress, style, cardLayout) — so what remains (image decode during scroll, focus-driven recomposition, the absence of the skyline feed's settled-focus prefetch policy on this plain LazyColumn) needs a device profile rather than a guess. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(tv): smooth For You focus scrolling * fix(tv): hide menu scrim on settings --------- Co-authored-by: rxwatcher <rxwatcher@users.noreply.github.com> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sync upstream into prairie-android with Silo→Prairie rebrand: settings contract/language catalogs, playback buffer architecture, specials-first season order, TV option dialog viewport fixes, For You list/top bar fixes, and related shared/TV/phone changes. Preserve Prairie-only Live TV DI bindings, X-Prairie-* + ImageFormats auth headers, American spelling in settings copy, and pinned Action SHAs. Move leftover org/siloserver paths to org.prairieserver.prairie and scrub brand tokens across the tree. Co-authored-by: Jonah May <JonahMMay@users.noreply.github.com>
|
Important Review skippedToo many files! This PR contains 306 files, which is 206 over the limit of 100. To get a review, reduce the PR to 100 files or fewer by splitting it into smaller PRs or changing its base branch. Upgrade to a paid plan to raise the limit. Usage-priced reviews support at most 300 files. ⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Plus Run ID: 📒 Files selected for processing (306)
You can disable this status message by setting the Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
SharedModelsCoverageTest still expected specials last; sortedForDisplay now places specials before regular seasons (SeasonDisplayOrderTest). Co-authored-by: Jonah May <JonahMMay@users.noreply.github.com>
Add focused unit tests for newly synced onboarding models/API, invitation auth routes, downloaded-subtitle URL rebasing, and QualityPresets edge cases so :shared:koverVerify clears the 95% line floor after the Silo sync. Co-authored-by: Jonah May <JonahMMay@users.noreply.github.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
Prairie-android was 168 commits behind
Silo-Server/silo-android, missing settings-contract/language catalogs, playback buffer architecture, specials-first season order, TV viewport/For You fixes, and related upstream work.Approach
Merged
upstream/mainwith the established sync playbook:X-Prairie-*+ImageFormats, Kover gate, pinned Action SHAsDepends conceptually on the prairie-server settings-contract sync PR.
Testing
Android SDK unavailable in this environment (
ANDROID_HOMEunset) — compile/tests not run locally. Rely on CI.AI Disclosure
Checklist