Commit Graph
12 Commits
Author SHA1 Message Date
Admin 0771a69d7d Acknowledge unsubscribes, and nothing re-rings after a restart
Two field reports, one chain: a dismissed alarm came back. Measured on the
emulator, three separate causes, all fixed here.

1. The alarm-clock entry launched an activity. On Android 14+ that launch is
   refused from the background and then *deferred*, so the window surfaced
   later — after the alarm had been acknowledged — and the siren played again
   for a delivered alert. The entry is now a broadcast to AlarmWakeReceiver,
   which only wakes the process and re-posts the takeover notification, and
   ignores an entry that outlived its arming. The one activity PendingIntent
   left is the alarm-clock show intent, which only the user can invoke.

2. A dismissal could be lost. `acknowledgeAlarm` returned early when the
   device identity had not loaded, and the queue's entry required a deviceId,
   so the ack was silently dropped and the ledger still owed the alarm. The
   queue accepts an entry without one (measured: the service takes a
   device-token ack with no deviceId) and the flush resolves the identity.
   Nothing raises — and the armed service does not start listening — until the
   queued dismissals have been read, so a restart cannot ring before the seed.

3. Acknowledging now unsubscribes the device (owner ruling: a dismissal is
   leaving). The ack settles the ledger first, then the subscription row goes;
   the takeover closes itself and Home reports "Acknowledged — this device
   unsubscribed". A leftover subscription was a live address that kept ringing
   on every launch.

Also: an alarm is only sounded for an alert this device still follows (or a
trigger event that actually arrived), and the takeover never renders the raw
alert id as its headline — that fallback is what made a stale launch look like
an alarm for "dbf1a4c2-…".

Verified on the emulator: join → trigger → acknowledge → subscription row gone,
alert delivered, app returns Home by itself; force-stop and reopen is silent
for the whole session (no audio, no notification, no takeover).
2026-10-20 00:48:33 +01:00
Admin 06afd92647 Reach the screen in the background, and leave it again
Measured on an Android 15 emulator with the app backgrounded and the display
asleep: the siren played, the platform refused the takeover
("Background activity launch blocked! … BAL_BLOCK"), and the alarm was
destroyed mid-ring by its own receipt ack.

Takeover:
- the foreground service posts an alarm_firing notification whose
  full-screen intent raises AlarmActivity — the only background launch
  Android 14+ permits. Verified with the display asleep: wakefulness
  Asleep → Awake, topResumedActivity=.AlarmActivity, the FSI on the record.
- the cancel lives on the four real silences (disarm, stop, JS ack, activity
  destroy), not in AlarmPlayer.stop: play() calls stop() first, which
  retracted the notification ~120 ms after posting it.
- "Back to alerts" now finishes the alarm window (AlarmModule
  finishTakeover). A route pop is not an activity: the window stayed on top
  for good with show-when-locked, keep-screen-on and no Recents entry — the
  reported "no way back to the main menu".

The native-delivery contract test said "no notification ever announces an
alarm"; that was falsified on device, so it now pins one FSI notification and
a silent firing channel instead.

Verified end to end on the emulator (two runs, one with the screen off):
join without an account, armed service, background trigger → wake + takeover
+ siren, acknowledge → silence, notification retracted, alert delivered,
takeover closed by itself, Home reachable, reopening silent.
2026-10-19 20:40:38 +01:00
Admin 98625adb25 [fix](client): arm in the background, make dismiss final, and leave the takeover
The reported failures are one missing acknowledgement and one missing service
start.

- Alarms only fired with the app in focus: the armed WebSocket lives in the
  native foreground service, and nothing started it — the only entries were a
  button on one alert screen and a toggle in Settings. The provider now arms the
  active subscription itself (asks for POST_NOTIFICATIONS, uses the device token
  the service can hold, throttles a refused start to once per five minutes), and
  Home says whether the background listener is really running instead of
  presenting the on-screen socket as coverage.
- Dismissed alarms came back: the takeover never sent the ack. acknowledgeAlarm
  existed and was never called, so the service still believed this device owed
  the alarm and replayed it on every connect/poll. The acknowledge path now
  persists the dismissal (durably, retried) and records it locally, so a status
  report of the trigger it already dismissed cannot raise the takeover again.
  The record tolerates a dismissal with no event id (the native launch carries
  only the alert) and is pinned to the concrete event as soon as one is known.
- No way back from the takeover: acknowledge now shows a Back to alerts action,
  hardware back works once the device is silent, and the screen leaves by itself
  when the alert ends for this device.
- A destroyed alert answers 401 to its device token (the subscription went with
  it). That is terminal: acknowledgements and the status poll stop retrying the
  dead credential and mark the subscription ended instead of looping.
- Settings' start-listening button built the socket URL without the device
  token, so it could never arm a subscriber's service.

tsc clean, 144 tests (15 new: dismissal memory, background arming, dead
credential, unnamed ack queue).
2026-10-19 19:34:36 +01:00
Admin 31cff32824 [feat](client): join without an account; the token the join returns is the identity
There is no anonymous session to start and nothing to mint: the app comes up on
Home with no account, and the first QR scan performs the unauthenticated
joinAlert, which returns a deviceToken the client stores with the alert. That
token — not a user, not a session — is what the subscriber calls carry as
X-Device-Token, and what the socket puts in ?deviceToken=.

- apiClient takes an optional device token per request; when present it is sent
  instead of the access token, because it is the narrower credential
- alarmApi.joinAlert returns it; getAlert/alarmStatus/ackAlarm/
  removeSubscription/qrImage and the socket URL accept it
- the alert record keeps the token beside its subscribe token, so a queued ack
  for an alert that is gone still resolves the right device
- login is now a destination, never a wall: without a session the app is the
  subscriber view, and the login screen's 'continue without an account' button
  is gone because there is nothing to continue from

tsc clean, 129 tests (8 new ones pin the header transport and the
no-access-token join).
2026-10-19 18:41:49 +01:00
Admin a375dd3db9 [test](client): pin the socket URL to the instance path
The socket move from a direct /ws/alarm host:port URL to ws(s)://{instance}/api/alarm/ws/alarm is a wire-contract change; assert the exact URL construction so a future connection refactor cannot silently reintroduce a second origin.
2026-10-19 02:34:20 +01:00
Admin dcfc9a5df8 [feat](client): scan an instance-relative join payload
The service mints /api/alarm/a/{alertId}#{token} with no host, so the QR renders against the configured instance and keeps working when the instance moves or is reached by another name. The scanner now matches path shape and fragment and never the host, so legacy alarm.dcntrlized.org codes and other instances codes still join; error copy distinguishes a malformed code from a wrong-alert token.
2026-10-19 02:34:20 +01:00
Admin fc66b8e088 [feat](client): generate a cryptographic per-install device id
The device id is the subscriber-only pseudonymous handle, used as the join idempotency key and to address this device in the delivery ledger. Use SecureRandom through the native bridge, validate it against the service limits, and keep hardware identifiers out of it entirely. Device labels describe the device role, never a slice of the id, and the subscriber list renders the wire id/created/lastSeenAt fields.
2026-10-19 02:34:02 +01:00
Admin a78c4cea14 [feat](client): show what stands between this phone and a real alarm
The blocking grants (exact alarms, full-screen intent, notifications) and the advisory battery exemption are surfaced as readiness, not as warnings that still allow arming. Listening state is read from the persisted native record on resume so a process-killed service cannot leave a stale armed claim on screen.
2026-10-19 02:34:02 +01:00
Admin a804223139 [feat](client): deliver a trigger as a real alarm-clock takeover
A notification is not a delivery path: Android 14 restricts full-screen intents to apps it classifies as alarm apps, and setAlarmClock is both the strongest alarm API and the evidence for that classification. Schedule the takeover through AlarmManager instead of startActivity, keep a standing alarm-clock entry while armed, restore it after reboot (the wake also re-arms the socket), and persist the honest listening state so a platform stop is visible. Run the socket as specialUse on API 34+ (dataSync is capped at 6h/24h on 35+) and keep onTimeout as a guard. Restore the Lead release build placeholder so a plain-HTTP instance is reachable, and allow the app to arm only when exact alarms and full-screen intent are actually granted.
2026-10-19 02:34:02 +01:00
Admin 5fa1e8e83e [feat](client): make delivery durable with an offline check-in and ack queue
The server ledger is cleared by an explicit ack, never by the check-in response, so a dismissal that cannot reach the service must be persisted and retried or the device rings again later. Add a durable pending-ack queue (404 means the alert ended: settled, drop it), map the 409 ERR_ALARM_TRIGGERED_OFFLINE check-in into a normal trigger for the same takeover, and make a 404 an ending rather than a missed alarm. This also carries the expiry/endings state the screens render.
2026-10-19 02:30:51 +01:00
Admin a29d9b775e [feat](client): address one instance origin instead of connection modes
Apps reach every service through Maestro on one port; direct service addressing is no longer a supported deployment, and Maestro has no per-service hostname or host-port route. Replace the maestro/direct modes and the serviceRef lookup with one configured origin and the fixed paths /api/alarm/{action,auth,ws}, defaulting dev builds to 127.0.0.1:8080 through adb reverse. The old mode-specific body wrapping and the direct-mode socket branch go with it.
2026-10-19 02:30:51 +01:00
Admin 2df34ab938 init 2026-10-18 22:42:00 +01:00