Skip to content

feat(appsettings): register this device as a companion app and adopt the bootstrap seed (#77) - #131

Merged
Adron merged 2 commits into
parity/queuefrom
issue/77-app-settings-register
Sep 16, 2026
Merged

Adron merged 2 commits into
parity/queuefrom
issue/77-app-settings-register

Conversation

@Adron

@Adron Adron commented Sep 16, 2026

Copy link
Copy Markdown
Member

Closes #77. Part of epic #76. Foundation for #78 (sync) and #79 (Applications screen).

New :core:appsettings module owning this install's identity in the account-level companion-app
registry, plus the register / bootstrap / deregister lifecycle.

The appKey is interlinedlist-android, and it must never change

appKey is free-form — there is no registration step or allow-list. The server creates the
namespace on first sight and seeds the shared app-catalog entry from the appDisplayName sent with
the first device registration ("ignored afterward"). The web's own first consumer uses
visual-introspection for the Visual Introspection macOS app, so the convention is a human-readable
slug naming the application.

It must never change: the key is the namespace. Changing it would orphan every device
registration and settings document under the old key — the user would see a second, empty
"InterlinedList Android" entry, and a fresh phone would seed from nothing. Stated in the KDoc on
CompanionApp and pinned by a test.

The device id is a random UUID, not a hardware identifier

android- + a random v4 UUID, generated on first use and written with commit() (not apply())
so a process death cannot lose it and strand an orphan registration.

It lives in a plain prefs file, deliberately outside the encrypted session store, because
SessionStore.clear() wipes that on every sign-out — an id that changed per sign-out would leave a
trail of dead devices. Deliberately not ANDROID_ID, the serial, an advertising id or the IMEI:
Play restricts persistent hardware identifiers and Android's guidance is an app-scoped self-generated
id. Sign-out keeps the id (the registry is already per-user) but clears the bootstrap flag and seed,
so signing in as someone else seeds from their main workstation.

The device label is now shared, not computed twice

DefaultAuthRepository was building "InterlinedList Android · ${Build.MODEL}" inline for
sync-token. That now comes from DeviceLabelProvider (interface in :core:common, exactly like
SessionTokenProvider) and is reused as the registry's deviceName — so the same phone reads
identically under Settings → Sessions and Settings → Applications
, which is what the issue asked
for. Format unchanged, trimmed to the registry's 120-char limit.

Registration, bootstrap, deregistration

Driven from the signed-in shell beside the existing push-token hook — entering the shell is precisely
"just signed in" or "launched signed in", and POST …/devices is register-or-refresh. It is off
the sign-in critical path and every call returns ApiResult, so a failed register never blocks
sign-in
and retries next launch.

A brand-new install then reads …/bootstrap?deviceId= and adopts the resolved document verbatim
into AppDeviceStore.pendingSeed — that pending value is the explicit seam for #78. A 404
({"source":"none"}) marks bootstrap resolved so the account's first machine never asks again; other
failures stay pending.

Deregistration reuses #46's mechanism exactly: AppDeviceSessionTeardown : SessionTeardownTask,
bound @Binds @IntoSet, run by AuthRepository.logout() before the token is cleared. Both exits
(Sign out, and account deletion, which funnels through the same logout()) are covered, and a 404
from the DELETE counts as success since teardown must be idempotent. No second hook was invented.

A real bug the live probe caught

kotlinx.serialization omits a property equal to its default, so platform was being stripped from
the registration body — and the live API answers that with 400 {"error":"platform is invalid"}. A
test caught it first; platform is now a required DTO field.

Live probe — created and removed

Under throwaway key zz-probe-77-scratch: two devices and one device-scoped settings document.
Confirmed POST/GET/DELETE devices, the settings PUT, and bootstrap resolving source: "default-device" with defaultDeviceId/defaultDeviceName. All deleted —
(independently re-verified: zz-probe-77-scratch, interlinedlist-android and
visual-introspection all return {"devices":[]} and 404 for their documents.)

Residue I could not remove: the shared app-catalog entry for zz-probe-77-scratch (display name
"Probe 77 (delete me)"). There is no documented endpoint to delete a catalog entry — DELETE /api/user/app-settings/zz-probe-77-scratch answers {"deleted":false}. It may show as an empty
extra row in the web's Settings → Applications
; removing it needs a server-side delete.

Verification

./gradlew :app:assembleDebug testDebugUnitTest → BUILD SUCCESSFUL, whole repo green. 35 new
tests
: register posts the documented body, versions omitted when absent, deregister
path/idempotent-404/real-failure, bootstrap query + default-device parsing + 404-as-NotFound; the
id is generated once and stable across a simulated restart, matches the server's regex, differs per
install, and sign-out keeps it while clearing account state; teardown deregisters on sign-out and
on account deletion through the SessionTeardownTask contract
; a failed register neither throws
nor seeds and retries; a failed deregister does not stop sign-out.

Notes for #78

Read AppDeviceStore.pendingSeed, apply it, set it to null. Writing settings back needs
body-baseVersion compare-and-swap (first write baseVersion: 0; a 409 returns current to
rebase on) and must respect the shared ~60 writes/min/appKey budget that registration also draws
from. Rename/promote (PATCH) is #79's surface.

…its bootstrap

The Android app is a companion app, but it has never told the account so: it is
absent from the web's Settings → Applications, and a new phone starts from
defaults instead of the user's main workstation.

Adds `:core:appsettings`, a small shared module that owns this install's identity
in the account-level registry (`/api/user/app-settings/{appKey}/…`). It is a
`:core:` module rather than part of `:feature:profile` because two different
callers need the device identity: `:feature:auth` (the sign-in device label) and
the Applications screen still to come (#79), and no feature module may depend on
another.

- appKey is `interlinedlist-android`, and it is permanent. The key is free-form —
  the server creates the namespace on first use and seeds the catalog entry from
  `appDisplayName` (the web's own macOS client uses `visual-introspection`) —
  so changing it later would orphan every device and settings document stored
  under the old one. Pinned in KDoc and in a test.
- Device id is `android-` + a random v4 UUID, generated once and persisted with
  `commit()` in plain prefs (outside the encrypted session file, which sign-out
  wipes). Deliberately not ANDROID_ID, the serial, an advertising id or the IMEI:
  Play restricts persistent hardware identifiers and Android's guidance is an
  app-scoped, self-generated id.
- The device label is now produced once, behind `DeviceLabelProvider` in
  `:core:common`, and shared by `sync-token`'s `deviceLabel` and the registry's
  `deviceName`, so one phone reads identically under Sessions and Applications.
- Registration is driven from the signed-in shell, exactly like the push-token
  lifecycle: entering it is "just signed in" or "launched signed in", and the
  endpoint is register-or-refresh. It is off the sign-in critical path and every
  call returns ApiResult, so a failed registration never blocks sign-in and is
  retried on the next launch.
- A brand-new install then reads `…/bootstrap?deviceId=` and adopts the resolved
  document verbatim into `AppDeviceStore.pendingSeed`. Applying it to the device's
  preferences is #78's job; that pending value is the seam. A 404
  (`{"source":"none"}`) marks bootstrap resolved so the account's first machine
  never asks again.
- Deregistration reuses #46's `SessionTeardownTask` multibinding, which
  `AuthRepository.logout()` runs before clearing the bearer token — the single
  path both "Sign out" and account deletion take, so no exit can bypass it.
  A 404 from the DELETE counts as success, since teardown must be idempotent.

Verified against the live API under a throwaway appKey (created and removed):
POST/GET/DELETE devices, the device-scoped settings PUT and bootstrap resolving
`default-device` from the main workstation. That probe also confirmed `platform`
is required — kotlinx.serialization was omitting it because it equalled its
default, which a unit test caught and the live API answers with
`{"error":"platform is invalid"}`.

Closes #77
@Adron
Adron merged commit 54a7ca7 into parity/queue Sep 16, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant