Memby is handed out from a NAS or web server rather than a store, so:
- UpdateChecker gains a static-manifest source alongside Gitea. A .json
update URL is read as a manifest ({version, apkUrl, notes}); anything
else is still treated as a Gitea host. apkUrl may be relative and is
resolved against the manifest's own URL.
- Release signing config reads memby.keystore from local.properties or
the environment. Without it the build still succeeds but warns loudly:
an unsigned APK will not install, and a changed key forces every user
to uninstall before they can update.
- release.ps1 bumps the version, runs tests, builds a signed APK and
assembles dist/out/ (landing page, latest.json, versioned APK) ready to
copy onto the NAS.
- Keystores and dist/out are gitignored.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
14 KiB
CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
What this is
Memby — an Android TV (Leanback) Emby client by ponzischeme89, written in Kotlin +
Compose for TV. It contains two surfaces over one shared data layer: the client app
(setup → profile chooser → home → player) and the system screensaver (DreamService,
labelled "Memby Screensaver"), which was the project's original purpose and still ships in
the same APK.
User-facing name is always Memby: app_name/screensaver_name/developer_name in
res/values/strings.xml, the MediaBrowser Client="Memby" auth header Emby shows in its
devices list, and on-screen copy.
Emby* class names (EmbyRepository, EmbyApi, EmbyServiceFactory, EmbyModels) are
kept on purpose: those types model Emby's API, and renaming them would make the code
lie about what it talks to. App-identity types are Memby*.
Repository layout
This is a two-language monorepo. app/ and benchmark/ are the Gradle build; server/
is an independent Go module (the Memby gateway) that Gradle does not know about, built
and run through Docker. docker-compose.yml at the root wires the gateway to Postgres and
Redis. The two halves are coupled only by an HTTP contract — see "Gateway mode" below.
Build, install, test
Requires JDK 17 and the Android SDK. deploy-debug.ps1 sets JAVA_HOME to Android Studio's
bundled JBR; do the same when invoking Gradle directly if the shell JDK isn't 17.
.\gradlew.bat assembleDebug # build APK -> app/build/outputs/apk/debug/
.\gradlew.bat test # JVM unit tests (app/src/test)
.\gradlew.bat :app:testDebugUnitTest --tests "*MediaBadgesTest" # one test class
.\gradlew.bat installDebug
.\deploy-debug.ps1 -Serial 192.168.20.3:41479 # force-stop, install, wake, relaunch
.\gradlew.bat :benchmark:connectedCheck # macrobenchmarks; needs a connected TV
For the gateway (from server/):
go build ./... && go test ./... # add -buildvcs=false on Windows if .git is unusable
docker compose up -d --build # from the repo root; needs .env (see .env.example)
local.properties must contain sdk.dir=... when building from the CLI.
Lint has abortOnError = false (media3's @UnstableApi opt-in check would otherwise fail
the build), so lint failures do not surface at build time.
Unit tests are plain JUnit 4 with no Android/Robolectric dependency — logic that needs
testing must live in a pure function or a plain data class (mediaBadges, HomeUiState,
millisecondsToTicks, ringColorFromHex, EmbyProfile handling are the existing examples).
Identity
One name everywhere: com.ponzischeme89.memby is the Kotlin package, the Gradle
namespace and the applicationId. Identity types are Memby* (MembyApp,
MembyDreamService, Theme.Memby).
Historical note, because old APKs and TVs still carry it: through v0.1.52 the package was
com.mattcohen.embyscreensaver and the applicationId was com.mattcohen.embyclientsname.
Both changed in v0.1.53. applicationId is the install identity — changing it makes
every TV treat the build as a brand-new app: the old icon stays until uninstalled, the
DataStore session is gone, and users sign in again. Treat any future change to it as a
migration, not a rename. adb commands, the benchmark packageName and FileProvider
authorities all derive from it.
Versioning. versionCode is derived from versionName: major*10000 + minor*100 + patch (0.1.53 → 153). Bump both together — the in-app updater compares versionName,
while Android refuses an APK whose versionCode went backwards. release.ps1 -Version
rewrites both, so prefer it over editing the build file by hand.
Releases. APKs are self-hosted (NAS or any web server), not on a store. release.ps1
builds a signed APK and assembles dist/out/ — index.html (landing page from
dist/template/), latest.json (the manifest the app polls) and the versioned APK.
Release signing reads memby.keystore and friends from local.properties; with no
keystore the build still succeeds but emits an unsigned APK and logs a warning. The key
matters more than the code: Android identifies an app by applicationId plus signing
key, so a changed key forces every user to uninstall and reinstall.
UpdateChecker supports two sources, chosen by URL shape in isManifestUrl — a .json
URL is a static manifest, anything else is a Gitea host. resolveApkUrl lets a manifest
use a relative apkUrl. Both are unit-tested in UpdateSourceTest.
Architecture
Manual DI. ServiceLocator (initialised in MembyApp) holds the single SettingsStore
and EmbyRepository. Activities, composables and MembyDreamService all read from it —
there is no DI framework and no per-screen repository construction.
Backend selection is build-time config. Two Gradle properties in gradle.properties
become BuildConfig fields, both read through data/ServerConfig.kt:
memby.gatewayUrl→MEMBY_GATEWAY_URL. Non-blank puts the app in gateway mode.memby.serverUrl→EMBY_SERVER_URL. The Emby address for the direct path; when set, the repository'sactiveServerUrlprefers it over the persistedSettings.serverUrlandSetupScreenhides the address field.
Prefer activeServerUrl over snapshot.serverUrl in new repository code, or a hardwired
build silently falls back to a stale saved address. resolveServerUrl holds the
precedence rule as a pure function so it can be unit-tested.
Gateway mode. EmbyRepository is dual-path: every method starts with a
if (ServerConfig.isGateway) branch that calls GatewayApi, then falls through to the
original Emby code. Both paths must keep working — the direct path is the fallback when
the container is down. Specifics worth knowing:
- The gateway forwards Emby's item JSON verbatim, so
BaseItemis the single item model in both modes. Only the envelope differs (data/model/GatewayModels.kt). Settings.tokenholds the gateway token in gateway mode and the Emby token otherwise;Settings.serverUrllikewise holds whichever backend was signed into. No separate storage slots.supportsBatchHomedrivesHomeViewModel: gateway mode fetches all four rows with onegetHome()call, direct mode keeps the four-way parallel fan-out.- Rows are server-composed.
/v1/homereturns arowsarray (id, title, kind, items) andMainActivity.serverHomeRows()renders it verbatim, so a new row type ships without an app release — an unknownkindfalls back to poster cards rather than disappearing.state.rowsis empty on the direct path, wherehomeRowsFor()composes rows locally. Two things are easy to miss: rows hold their own copies of items, soHomeViewModel.updateUserDatamust map overrowstoo or an optimistic favourite won't show on a recommendation card; andloadBatchHomekeeps the previous rows when a response arrives with none, because the gateway omits recommendations while they build. HomeCache.rowspersists them for cold start. New fields there need defaults — an existing install decodes a cache written by the previous build.- Image URLs are built by the private
imageUrl()helper. Coil fetches plain URLs with no interceptor, so the credential rides in the query string either way —t=for the gateway proxy,api_key=for Emby. - Video always direct-plays from Emby. The gateway returns a URL; it never proxies a stream. Don't route playback through it.
- Search exists on the gateway (
repository.search) but has no UI yet.
The wire contract is pinned from both ends: GatewayPayloadTest.kt / ServerHomeRowsTest.kt
(Kotlin) and internal/api/api_test.go (Go). Change a field name or a row kind and one
of them should fail.
Imported library. server/internal/library copies Emby's catalogue into
library_items (payload stored verbatim as JSONB, hot fields promoted to columns for
filtering plus a generated tsvector). Search and the recommendation candidate pool read
from it, falling back to Emby when it is empty — so both paths must keep working. It is
imported with EnableUserData=false on purpose: the table is shared by the whole
household, so watched/favourite/resume state must never be cached there and still comes
from Emby live. A full import mark-and-sweeps on synced_at; incremental uses
MinDateLastSaved with a minute of overlap.
Maintenance mode gates the whole /v1 subtree (that's why Routes() builds a
separate v1 mux) with a 503 carrying maintenance: true. /healthz, /readyz and
/admin sit outside it deliberately. State lives in Postgres and is cached in memory,
re-read every 30s. Client side, parseMaintenanceMessage pulls the operator's message out
of the 503 body (trusting only the known message field, truncated) and
HomeUiState.maintenanceMessage — distinct from statusMessage, which is the ordinary
slow-connection banner — swaps the whole content area for ui/MaintenanceScreen.kt. The
navigation rail stays mounted beside it so Settings and Switch user still work, and the
retry button takes contentFocusRequester (with focusProperties { left = … } back to
the rail) because otherwise D-pad focus has nowhere to go once the rows are gone.
Row analytics. data/analytics/RowAnalytics.kt buffers impression/focus/select events
with dwell timing (injectable clock, unit-tested) and HomeViewModel flushes every 20s,
on ON_STOP, and on dispose. Fire-and-forget by design — reportRowEvents swallows
failures, because telemetry must never surface on a TV. Aggregates are read at query time
in store.RowStats; raw events are pruned after 90 days.
Admin interface is server/internal/api/admin.html, a single embedded page (no build
step, no CDN — a strict no-dependency page is the whole point). It polls
/admin/api/status every 5s. Guarded by MEMBY_ADMIN_TOKEN; unset means every /admin
route 404s.
Recommendations live in server/internal/recommend: profile.go is pure scoring
(recency-weighted genre/studio affinity, exclusion of anything seen) and engine.go does
the Emby fan-out. Both are unit-tested without a network — engine.go takes a narrow
Source interface so tests inject a fake. The engine never runs on the home request path:
rows come from the r:<userId>:rows cache, and a miss triggers a deduplicated background
rebuild while home returns immediately. That key is intentionally outside the u:
namespace that mutations wipe; only a finished playback retires it.
EmbyRepository is the only place that talks to Emby. It keeps a @Volatile snapshot
of Settings collected from DataStore so synchronous callers (URL builders,
rotationIntervalMillis) don't suspend, and it caches the Retrofit EmbyApi instance,
rebuilding only when the base URL changes. All image and stream URLs are built here with
api_key appended. Errors reaching the UI go through friendlyEmbyError — never surface
raw HTTP bodies, which can contain tokens (the OkHttp logging interceptor is pinned at
Level.NONE for the same reason).
Emby query conventions. List endpoints request the narrowest Fields /
EnableImageTypes set that the row needs (getHomeItems enforces this); full metadata is
fetched only via getItemDetails after D-pad focus settles (140 ms debounce in
HomeViewModel.focusItem, with an LRU cache and cancellation of the in-flight job). Adding
fields to a home query is a startup-cost regression — extend the detail call instead.
Emby time values are 100-ns ticks; convert at the boundary (millisecondsToTicks,
resumePositionMs).
Multi-profile session state. SettingsStore stores a list of EmbyProfile (server,
token, userId, plus that profile's cached home JSON) and mirrors the active profile into
the flat top-level keys the rest of the app reads. switchProfile/saveSession must keep
both in sync; legacyProfile() synthesises a profile from the flat keys for installs that
predate the list. deviceId is intentionally preserved across clearSession().
Home startup path. HomeCache (last successful home response) is persisted per profile
and used as the initial HomeUiState, so the launcher renders rows before the network
returns; sections then refresh in parallel under a Mutex and re-persist. Playback stops
are broadcast through repository.playbackStops and refresh only the Continue/Next-Up rows.
Screensaver hosting. ScreensaverContent is shared by MembyDreamService and
ScreensaverActivity. A DreamService is not a ComponentActivity, so
DreamLifecycleOwner supplies the ViewTree lifecycle/ViewModelStore/SavedState owners
Compose requires; D-pad handling lives in the composable while the hardware Play/Pause key
is intercepted in dispatchKeyEvent and routed via the ScreensaverActions holder.
Playback from the dream finish()es first and starts PlayerActivity on a delayed main-
thread post to avoid the "activity behind the dream" race.
In-app updates. UpdateChecker polls a user-configured Gitea release
(/api/v1/repos/{owner}/{repo}/releases/latest, token auth for private repos), downloads the
APK and hands it to the system installer via FileProvider. Because replacing the APK kills
a running Dream and leaves a black surface, UpdateRecoveryReceiver catches
MY_PACKAGE_REPLACED and relaunches MainActivity with
EXTRA_LAUNCH_UPDATED_SLIDESHOW.
Playback uses Emby's direct stream (/Videos/{id}/stream?static=true) — no
PlaybackInfo/transcode negotiation, so exotic codecs may fail. The
media3-exoplayer-hls dependency is already present for when that's added. Progress is
reported back to Emby via reportPlaybackStarted/Progress/Stopped.
Performance instrumentation. PerformanceMonitor (JankStats) is debug-only and logs to
tag EmbyClientPerf; benchmark/ is a com.android.test macrobenchmark module currently
targeting the debug build (suppressErrors = DEBUGGABLE), so its numbers are
debug-influenced.
UI conventions
Use androidx.tv.material3 components (Button, Card, Text) rather than the phone
Material 3 ones. MainActivity.kt, HomeComponents.kt and ScreensaverContent.kt are the
three large files — new screens generally belong in ui/<feature>/ rather than growing
them further. Focus handling is explicit (FocusRequester, focusRestorer, focusGroup);
everything must be reachable by D-pad only.