feat(appsettings): register this device as a companion app and adopt the bootstrap seed (#77) - #131
Merged
Merged
Conversation
…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
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.
Closes #77. Part of epic #76. Foundation for #78 (sync) and #79 (Applications screen).
New
:core:appsettingsmodule owning this install's identity in the account-level companion-appregistry, plus the register / bootstrap / deregister lifecycle.
The
appKeyisinterlinedlist-android, and it must never changeappKeyis free-form — there is no registration step or allow-list. The server creates thenamespace on first sight and seeds the shared app-catalog entry from the
appDisplayNamesent withthe first device registration ("ignored afterward"). The web's own first consumer uses
visual-introspectionfor the Visual Introspection macOS app, so the convention is a human-readableslug 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
CompanionAppand 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 withcommit()(notapply())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 atrail 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
DefaultAuthRepositorywas building"InterlinedList Android · ${Build.MODEL}"inline forsync-token. That now comes fromDeviceLabelProvider(interface in:core:common, exactly likeSessionTokenProvider) and is reused as the registry'sdeviceName— so the same phone readsidentically 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 …/devicesis register-or-refresh. It is offthe sign-in critical path and every call returns
ApiResult, so a failed register never blockssign-in and retries next launch.
A brand-new install then reads
…/bootstrap?deviceId=and adopts the resolved document verbatiminto
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; otherfailures stay pending.
Deregistration reuses #46's mechanism exactly:
AppDeviceSessionTeardown : SessionTeardownTask,bound
@Binds @IntoSet, run byAuthRepository.logout()before the token is cleared. Both exits(Sign out, and account deletion, which funnels through the same
logout()) are covered, and a 404from 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
platformwas being stripped fromthe registration body — and the live API answers that with
400 {"error":"platform is invalid"}. Atest caught it first;
platformis 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 settingsPUT, and bootstrap resolvingsource: "default-device"withdefaultDeviceId/defaultDeviceName. All deleted —(independently re-verified:
zz-probe-77-scratch,interlinedlist-androidandvisual-introspectionall 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-scratchanswers{"deleted":false}. It may show as an emptyextra 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 newtests: register posts the documented body, versions omitted when absent, deregister
path/idempotent-404/real-failure, bootstrap query +
default-deviceparsing + 404-as-NotFound; theid 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
SessionTeardownTaskcontract; a failed register neither throwsnor 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 needsbody-
baseVersioncompare-and-swap (first writebaseVersion: 0; a 409 returnscurrenttorebase on) and must respect the shared ~60 writes/min/appKey budget that registration also draws
from. Rename/promote (
PATCH) is #79's surface.