380 lines
22 KiB
Markdown
380 lines
22 KiB
Markdown
# Wolfskin TV container builds
|
||
|
||
Run from a full Git checkout with the upstream `v*` and `p*` tags available. The
|
||
host needs Docker, GNU Make, Bash, Git, and `flock` (util-linux), not Java or the
|
||
Android SDK.
|
||
|
||
```sh
|
||
git fetch origin --tags
|
||
make apk # clean, signed default release: three ABI splits + universal
|
||
make test # app default/debug suite plus wolfskin-design JVM/capture suite
|
||
make apk-debug # default debug, no secrets; MPV stub and no extra codec decoders
|
||
```
|
||
|
||
APKs remain under `app/build/outputs/apk/default/{release,debug}/`. Debug builds
|
||
are not playback-complete and must not be distributed as releases. Upstream's
|
||
`.github/workflows` and dependency version catalog are unchanged; release builds
|
||
use the catalog's exact Wholphin extension version (currently `0.2.4`). Do not
|
||
place local extension AARs in `app/libs`: upstream prefers them over the pinned
|
||
GitHub Packages dependencies.
|
||
|
||
Upload release assets renamed to `Wolfskin.apk` / `Wolfskin-<abi>.apk` for the
|
||
in-app updater. Gradle outputs
|
||
`Wholphin-default-release-<versionName>-<versionCode>[-<abi>].apk`; strip the
|
||
flavor, version, and code as well as replacing the prefix.
|
||
|
||
Upstream enables JVM unit tests only for debug variants; there is no
|
||
`testDefaultReleaseUnitTest` task. `make test` uses the closest default-flavor
|
||
task without changing upstream's test configuration.
|
||
|
||
## Upstream v1.1.0 integration
|
||
|
||
The release merge brings Kotlin `2.4.21`, Gradle `9.8.2`, AGP `9.5.0`, and
|
||
ExoplayerHdrUtils `0.4.1`. The pinned SDK/JDK image and serialized Gradle wrapper
|
||
remain compatible without changes; native Wholphin extensions remain `0.2.4`.
|
||
There are no committed dependency lockfiles or Gradle verification metadata.
|
||
Forgejo Actions stays disabled; upstream GitHub workflows remain dormant.
|
||
|
||
The app database upgrades from Room schema 35 through 40 to 41 on first launch
|
||
after installation. The upstream migrations add series track choices and a home
|
||
settings source preference. Downgrading with the upgraded database is not
|
||
supported: a TV installation is a one-way step and requires explicit approval.
|
||
The optional companion plugin can supply Discover credentials before locally
|
||
saved Seerr profiles; do not configure an administrator API key in place of the
|
||
household's per-profile Jellyfin login, which Conductarr uses for request routing.
|
||
|
||
Build only after committing a release merge: version names come from Git HEAD,
|
||
not the working tree. An uncommitted merge still builds with the old version name.
|
||
Neither an upstream merge nor its validation build authorizes a release or TV
|
||
installation.
|
||
|
||
## Wolfskin shell
|
||
|
||
Wolfskin UI lives in `app/src/wolfskin/java`, beside upstream source. The signed-in
|
||
entry in `MainContent.kt` calls `WolfskinApp`; Wolfskin-only routes use the single
|
||
`Destination.Wolfskin` subtype in the existing navigation stack. Other destinations
|
||
continue through upstream `DestinationContent`. Full-screen player/settings pages
|
||
cover the Wolfskin nav.
|
||
|
||
The independent `:wolfskin-design` module owns the shell components, fixed person
|
||
colours, focus policy, per-person root-view memory, palette and motion. Its
|
||
`tokens.json` is the copied approved TV-spec source, retaining the locked prototype
|
||
SHA and provenance. `shell-tokens.json` adds shell, grid and sheet measurements;
|
||
`home-tokens.json` adds Home geometry from that same locked prototype. Generate
|
||
the committed Kotlin, CSS and checksum manifest together:
|
||
|
||
```sh
|
||
python3 wolfskin-design/generate_tokens.py
|
||
python3 wolfskin-design/generate_tokens.py --check
|
||
```
|
||
|
||
`preBuild` checks the manifest against the source JSON, generator and generated
|
||
outputs. Editing any of them without regenerating fails the build. The shell uses
|
||
the generated palette rather than the retired `WOLFSKIN` theme-enum value; the old
|
||
numeric value is reserved.
|
||
|
||
Compose output maps source weights 600/650 to the platform Bold (700) face, which
|
||
resolves consistently in native captures; the approved JSON weights stay unchanged.
|
||
Nav, picker names, labels and pill weights use that same mapping. Nav label colour
|
||
fades for 200 ms ease-out; the blob retains its critically damped spring.
|
||
|
||
Settings pills have a separate 1 dp stroke (2 px on the stage), not a focus ring.
|
||
The stroke is inset within the fixed 20 dp / 40 px height; horizontal border-box
|
||
padding includes it. The 6 px dash / 4 px gap follows the browser's 3:2 stroke-width
|
||
ratios. Resting borders are white at 22% opacity; focused borders use ink at 40%.
|
||
|
||
The small gear under the final person opens Settings with Requests focused.
|
||
Settings groups hand off to existing upstream pages; Requests opens the household
|
||
Coming-in sheet described below. **Advanced settings → Wolfskin screens** is the persistent
|
||
rollout switch: turning it off renders upstream Home and library destinations with
|
||
the upstream Backdrop and top scrim, while retaining Wolfskin's nav, picker and
|
||
Settings list. Native Up traversal stays in content until the first focusable row,
|
||
then enters the nav. Turning the switch on renders Wolfskin Home; later-slice
|
||
screens remain explicitly labelled navigation targets.
|
||
|
||
Debug people, tabs and initials come from the shell-only projection of the locked
|
||
prototype fixture in `app/src/debug/assets/wolfskin-shell.json`; release resources
|
||
do not package it. Saved Jellyfin identities use existing upstream services. The
|
||
shell makes no Conductarr calls and does not add Conductarr sign-in.
|
||
|
||
Native Roborazzi captures use Robolectric SDK 34 and
|
||
`w960dp-h540dp-xhdpi` (1920×1080 at density 2.0). `make test` writes capture PNGs and
|
||
the sampled Compose nav-spring curve to `build/wolfskin-captures/`. Key replays
|
||
exercise the Android event adapter, including the 6/8/10/11-press shell paths.
|
||
The profile hand-off capture targets the real `HomeSettingsPage`; Playback is a
|
||
separate press-count path.
|
||
|
||
Robolectric SDK 28/30/34 runtimes resolve through Gradle's existing persistent
|
||
dependency cache, then copy to `wolfskin-design/build/robolectric-sdk/`. Test workers
|
||
use that local directory in offline mode instead of downloading Android JARs into
|
||
the container's ephemeral home. A cache miss still needs Maven Central; rate-limited
|
||
hosts can seed the existing cache from SHA512-verified local Maven artifacts without
|
||
changing the wrapper or adding volumes.
|
||
|
||
Default-flavour launcher artwork and English, Hungarian and Polish string overrides
|
||
live in `app/src/default/res/`. Legacy Wolfskin strings in 25 translated
|
||
`main` resource folders have not yet been moved; non-default `appstore`/`firetv`
|
||
flavours therefore combine upstream icons with some legacy Wolfskin text. Those
|
||
flavours and a full locale migration are not certified by this slice. Debug
|
||
assembly runs `checkWolfskinBranding` against every packaged APK through AGP's public
|
||
artifact provider and AAPT2. It checks all string, plural and array configurations,
|
||
rejecting user-visible upstream branding while preserving the upstream legal
|
||
attribution and project URL. The check does not depend on incremental merge files.
|
||
|
||
## Wolfskin Home
|
||
|
||
Home is one lazy frame: a non-rotating resume → Next up → newest → pick hero,
|
||
Jellyfin shelves, per-destination "Your …" shelves and the Discover continuation.
|
||
The final action opens Discover at its top; Discover remains its named integration
|
||
target. Home's See all cards open the real `WolfskinSeeAllLists` grid, carrying
|
||
the complete underlying list rather than the visible shelf window. Title content
|
||
remains the later-slice target.
|
||
|
||
All Home components and immutable screen models live in `:wolfskin-design`.
|
||
The shell's remembered view owns vertical position, each row's selected card and
|
||
actual horizontal `LazyListState`, and the bounded six-hour ratings session cache.
|
||
Composing destinations bind `viewFor(route)` before navigation effects run, so a
|
||
new See all grid cannot borrow or clamp Home's scroller. Re-entry restores the
|
||
remembered track before the page with `scrollToItem`, preserving exact indices and
|
||
pixel offsets rather than animating back. Android focus stays with the shell;
|
||
cards use its logical focus, without requests against uncomposed lazy items.
|
||
Back preserves the viewport; returning to Home does not re-run focus-follow over
|
||
a manually scrolled saved view. The next arrow re-enters visible content when
|
||
scrolling moved focus off-screen.
|
||
Pointing changes focus without activation, cards activate on key-up, and held
|
||
OK events cannot activate twice.
|
||
Between shelves, an unvisited row selects the nearest visible card center even
|
||
when card widths differ. A visited row restores its column. Horizontal movement
|
||
keeps the track still while focus remains in its visible middle.
|
||
The initial skeleton parks focus on the nav. When the first content arrives,
|
||
an untouched fresh entry focuses hero button zero; intervening nav input prevents
|
||
that automatic transfer. Home and Libraries use the same `ShellContentFocus`
|
||
release/repeat guard and Back ladder.
|
||
|
||
`HomeReadFacade` is the only Home code importing Jellyfin API clients. Release
|
||
variants read the selected user's Resume, Next up, newest titles and destination
|
||
shelves, with app-private last-known-good projections through the shared atomic
|
||
disk cache. A missing saved library becomes a recovery panel; a saved library
|
||
stays visible when Jellyfin is unreachable. Playback resolves an actual film or
|
||
episode and checks Jellyfin before opening upstream's player. No season,
|
||
recommendation stub or different person's session is promoted to playback.
|
||
An unresolved person switch shows the same sign-in guard for Home as for native
|
||
library content, before mounting Home's source reads.
|
||
|
||
Debug variants additionally package the locked fixture and local artwork under
|
||
`wolfskin-design/src/debug/assets/wolfskin-home/`; release variants exclude them.
|
||
The fixture provider supplies the approved household data and Home states without
|
||
network reads in tests. Conductarr sign-in is not implemented here: real signed-out
|
||
Home hides every rating and shows "Discover needs sign-in". No Seerr fallback is
|
||
used. Catalogue data and ratings in debug captures are fixture-backed.
|
||
|
||
Home and Libraries share `TitleCard`, `Caption`, `LibrarySeeAllCard` and one
|
||
`WolfskinImages` loader. Images request exact slot sizes, use RGB565 for posters,
|
||
disable crossfades, share a 64 MiB memory/128 MiB disk cache, limit image work to
|
||
four reads and prefetch the next shelf. Rating plates start at 14 sp, shrink to 12 sp
|
||
and drop trailing entries rather than wrap. Their uncached read gate is 350 ms;
|
||
cached focus returns without that delay.
|
||
|
||
Focus rings and native lift draw outside fixed slots. The Saved nav chip uses its
|
||
own geometry; the Discover section uses a warning pill. Caption separators multiply
|
||
the existing muted colour alpha by the shared 40% token. Home-only untrimmed styles
|
||
and `exactLineBox` honor the declared CSS line boxes despite Android's intrinsic
|
||
paragraph minimums; shell and library typography retain their existing defaults.
|
||
Home See all supplies its locked 8px column gap; the shared Library tail retains
|
||
its existing 10px default rather than leaking that measurement into Home.
|
||
Vertical moves clamp to short target rows, including a final See all card.
|
||
|
||
`make test` includes `HomeCaptureTest` (23 native acceptance states) and the
|
||
data-driven paths in `wolfskin-home-presses.json`: play next 2, resume 3, Discover
|
||
4, deep-row Back/Down 9, Trending 13 and Trending's See all card 33 presses.
|
||
Home captures are written to `build/wolfskin-captures/home/`. Compare those images
|
||
side by side with the frozen spec captures; Compose Roboto/JPEG rasterisation,
|
||
the approved button-scale consolidation and removal of prototype sample pills
|
||
are recorded differences, not a claim of browser pixel equality.
|
||
|
||
## Requests under Settings
|
||
|
||
The Requests row and Coming-in sheet share one immutable household snapshot.
|
||
Ready requests do not count as open. Rows sort Failed, Downloading/Almost ready,
|
||
Checking/Searching, Waiting, Ready. The bounded window shows five rows, or four
|
||
with readiness expanded. Right expands Request/Download/Library/Playback; Left
|
||
collapses; Up/Down collapse while moving. Opening a title preserves the request
|
||
for Back, and closing the sheet restores its opener. Selecting a root or changing
|
||
person clears the title-return link. Shared's authenticated actor always reads
|
||
“by Shared”, never “by you”.
|
||
|
||
The debug-only provider loads `app/src/debug/assets/wolfskin-requests.json` and
|
||
its eight local posters, projected from the locked prototype fixture. Six stage
|
||
rows feed the running debug shell; the Shared and focused-rating records feed
|
||
native acceptance states. The identical projection is in design-test resources.
|
||
No “sample” or “example stages” pill is drawn. Release builds package none of
|
||
these assets and show Requests unavailable until Conductarr sign-in supplies
|
||
the generated-client data path. There is no Seerr fallback or handwritten HTTP.
|
||
Foreground refreshes once; an open sheet or unsettled requests refresh every
|
||
30 seconds while started. The immutable debug projection is loaded once, not
|
||
decoded again on every poll.
|
||
Thumbnails reuse `WolfskinImages` with a 96×144-pixel request, RGB565, no crossfade,
|
||
the shared 64 MB memory/128 MB disk cache and four-read limit. Actor separator
|
||
dots use the surrounding text colour; the 40% rule applies only to poster captions.
|
||
|
||
Uncached focused ratings wait 350 ms; moving away cancels the read. Read ratings
|
||
show immediately on return; sign-out clears visibility. This follows the frozen
|
||
page and spec 6.12, resolving the contradictory no-dwell sentence in spec 7.8.
|
||
Deterministic coroutine-clock tests cover cancellation, reentry, cached return,
|
||
sign-out and refreshed locale scores; a thin Compose test verifies actual Dutch
|
||
rendering and sign-out suppression. The approved nine-key Requests path is JSON
|
||
data replayed through the real shell. The Requests-only harness starts at the
|
||
Home-to-nav boundary; Home's actual hero and asynchronous entry have separate key replays.
|
||
|
||
Sheet dimensions, padding, glyphs, scales, colours and durations are generated
|
||
tokens. Scoped sheet line boxes retain CSS heights and baselines independently
|
||
of native leading rounding; shared shell/library typography is unchanged. Platform
|
||
Roboto remains required: the host browser used DejaVu Sans, so its width,
|
||
wrapping, ellipsis and text-block positions are not a wrapping-parity target.
|
||
For example, the 44-pixel “Coming in” ink is 239 pixels wide in the reference
|
||
and 194 pixels in native captures. No per-title compensation or font bundle is
|
||
used. Native comparisons must report those structural differences explicitly.
|
||
Readiness glyphs sit inline on the text baseline with no flex gap, matching the
|
||
frozen block-span cascade. Items wrap intrinsically: Roboto fits the expanded
|
||
readiness into two lines where the reference uses three; no grouping is forced.
|
||
|
||
The six rating marks retain the exact locked SVG source in
|
||
`wolfskin-design/request-marks/`; native-size PNGs and their hashes are generated
|
||
from the JSON dimensions with the locked contain fit (`.rr .rb` outranks `.crow img`).
|
||
Inside Coming-in rows they retain the prototype's dark `surface3` chips with 12-pixel rounded corners.
|
||
Loaded poster thumbnails have no placeholder fill beneath their clipped edge.
|
||
|
||
```sh
|
||
uv run --with cairosvg==2.9.1 python wolfskin-design/generate_request_marks.py
|
||
```
|
||
|
||
`RequestsCaptureTest` writes `st-081`…`st-086`, `st-088` and `st-111` at 1920×1080,
|
||
density 2.0. Title selection currently uses an explicitly labelled
|
||
`request-title/...` destination until the independent title screen is integrated;
|
||
this slice does not claim to implement title pages, TV sign-in, release or device
|
||
deployment.
|
||
|
||
## Libraries and See all
|
||
|
||
Movies, Shows and Anime use the shell's retained view and a continuous six-column
|
||
lazy grid. Up from its first row reaches Sort/Filter, then the nav; Back preserves
|
||
the card and scroll. Sort cycles A–Z, Recently added and Rating. The letter rail
|
||
exists only at 200 titles with A–Z selected. Card OK activates on key-up through
|
||
the same shell hold guard, without a long-press menu.
|
||
Down clamps a short final title row before entering the separate Try again / Show
|
||
more row; an empty slot cannot activate recovery. An unavailable library hides
|
||
its unknown item count.
|
||
|
||
Caption separators use 40% of the existing muted colour's alpha. The rail follows
|
||
the locked prototype CSS: bold 24 px letters, with the focused letter bold 28 px,
|
||
overriding the spec table's generic caption style for this component.
|
||
Library and Settings header initials use the muted colour over the person's fill;
|
||
navigation and picker avatars retain their dark initials.
|
||
|
||
The debug-only `wolfskin-library.json` and `library/assets/` contain the locked
|
||
prototype's titles and local poster art. Release libraries read through
|
||
`LibraryReadFacade`, with 200-item Jellyfin chunks and ten-minute per-server/person
|
||
snapshots using the existing atomic disk cache. Saved data is painted before a
|
||
quiet refresh; a failed refresh keeps it. Production ratings remain hidden until
|
||
Conductarr sign-in exists.
|
||
|
||
Home/Discover shelves open a full list through
|
||
`WolfskinSeeAllLists.open(person, key, title, fullList, navigation, loadNextPage)`.
|
||
The optional suspend page provider returns `LibraryPage(items, hasMore)` using the
|
||
generated Conductarr client. The grid reads within ten cards of its end, deduplicates
|
||
title identities, and pauses on Try again or Show more rather than looping.
|
||
Title activation pushes the explicitly named `library-title/{id}` destination for
|
||
the title-screen implementation; this slice does not implement title pages.
|
||
|
||
`LibraryCaptureTest` renders the sixteen approved library/See all states offline.
|
||
`LibraryKeysTest` reads the approved JSON path and reaches Spirited Away in exactly
|
||
20 Android key presses. Paging and snapshot tests cover duplicate-only reads,
|
||
failure/retry, stale copies, and person/server isolation. Captures are written to
|
||
the same `build/wolfskin-captures/` directory as the shell captures.
|
||
|
||
## Toolchain and disk use
|
||
|
||
`Dockerfile` pins Temurin JDK `21.0.11+10` by image digest, command-line tools
|
||
`23.0` and platform-tools `37.0.1` by archive and checksum, and build-tools
|
||
`36.0.0` by SDK package version. The SDK manager registers platform `37.0` and
|
||
its licenses; the build rejects any platform revision other than 2. An SDK
|
||
revision upgrade therefore requires a deliberate Dockerfile update.
|
||
Gradle comes from the committed wrapper. The image is built for `linux/amd64`;
|
||
the default local image tag is `wolfskin-build:sdk37-jdk21`.
|
||
Upstream's build timestamp means APKs are not byte-for-byte reproducible.
|
||
|
||
Gradle downloads and caches live in the named volume `wolfskin-gradle`, not
|
||
`/tmp`. Module outputs stay in the checkout. Builds run with the host UID/GID,
|
||
`--no-daemon`, and at most four workers. The repository's Git metadata is mounted
|
||
read-only, including when the checkout is a linked worktree.
|
||
|
||
Override `WOLFSKIN_BUILD_IMAGE` to change the locally built image tag and
|
||
`WOLFSKIN_GRADLE_VOLUME` to isolate a cache. `make build-image` builds only the
|
||
image. Do not delete shared caches during another build.
|
||
|
||
Every Gradle container holds a host-side exclusive lock for its entire run.
|
||
Concurrent targets sharing a cache queue rather than timing out on Gradle's
|
||
cross-container cache journal lock; a waiting invocation prints one line.
|
||
The default lock is `/var/tmp/${WOLFSKIN_GRADLE_VOLUME}.gradle.lock` (using
|
||
`wolfskin-gradle` when unset). Override `WOLFSKIN_GRADLE_LOCK_FILE` with a path
|
||
whose parent directory already exists. All callers sharing a volume must use
|
||
the same lock path on the Docker host. Do not delete the file while builds are
|
||
running or waiting. The lock releases automatically when the invocation exits.
|
||
|
||
## Release credentials
|
||
|
||
The default escrow is
|
||
`/opt/stacks/ventoy/machine-parity/manifest-tank/secrets/wolfskin/`. Check
|
||
`mountpoint -q /mnt/ventoy` before using that path. Set `WOLFSKIN_SECRETS_DIR`
|
||
to use another existing directory. Its required files are:
|
||
|
||
| File | Contents |
|
||
| --- | --- |
|
||
| `release.jks` | Separate Wolfskin PKCS12 keystore, RSA 4096, alias `wolfskin` |
|
||
| `release.properties` | Java properties `storePassword` and `keyPassword` |
|
||
| `github-packages-token` | GitHub token with `read:packages`, username `JustinZeus` |
|
||
|
||
The directory must be mode `0700`, each file mode `0600`, owned by the building
|
||
user. Never commit them or put their contents in logs. Back up the keystore and
|
||
passwords together: losing the release key forces a reinstall. Do not reuse
|
||
wolfskin-android's key or the gh CLI login token.
|
||
|
||
Release builds mount escrow read-only. The token is loaded inside the container
|
||
into upstream's `ORG_GRADLE_PROJECT_WholphinExtensionsUsername` and
|
||
`ORG_GRADLE_PROJECT_WholphinExtensionsPassword` environment hooks, never a Docker
|
||
command argument. The signing init script registers `wolfskin`; the nonsecret
|
||
`release-signing.properties` is mounted as `local.properties` so upstream's
|
||
`release.signing.config` hook selects it. Any host `local.properties` is hidden
|
||
only for the duration of this container. Keystore and passwords stay outside the
|
||
checkout and image. Missing credentials fail the release rather than silently
|
||
shipping the MPV stub.
|
||
|
||
## Versioning and local release markers
|
||
|
||
Upstream computes `versionName` from
|
||
`git describe --tags --long --match='v*'`, dropping the leading `v`, and computes
|
||
`versionCode` from the count of all local `v*` and `p*` tags. Preserve and fetch
|
||
those tags; a shallow or incomplete checkout can give an incorrect version.
|
||
Use the full `git describe` output as the human release name.
|
||
|
||
After committing the intended release, with a clean checkout and fetched tags:
|
||
|
||
```sh
|
||
make release-tag
|
||
make apk
|
||
```
|
||
|
||
The helper creates one lightweight `pwolfskin-YYYY_MM_DD` tag in UTC, appending
|
||
`_1`, `_2`, etc. on collision. Each marker adds one to `versionCode` without
|
||
changing upstream's `v*`-based version name. It never pushes tags, publishes a
|
||
release, or installs an APK. Do not delete published release markers. Tag
|
||
creation and publication must be serialized between release operators.
|
||
|
||
## Inspecting release artifacts
|
||
|
||
Use `apksigner verify --verbose --print-certs APK` and
|
||
`aapt2 dump badging APK` inside the build image. Compare the signer SHA-256 with
|
||
`keytool -list -v -alias wolfskin -keystore /path/to/release.jks` (enter its
|
||
password privately). Inspect APK ZIP entries for real native MPV and ffmpeg
|
||
libraries for each shipped ABI; a successful Java compilation alone does not
|
||
prove that codec extensions are included.
|