Skip to content

feat: register with the wake gateway and receive wakes - #1847

Open
coreyphillips wants to merge 13 commits into
masterfrom
feat/wake-gateway-phase-1
Open

coreyphillips wants to merge 13 commits into
masterfrom
feat/wake-gateway-phase-1

Conversation

@coreyphillips

@coreyphillips coreyphillips commented Nov 10, 2026 •

Copy link
Copy Markdown
Contributor

Part of #1804 (Phase 1)
Twin: synonymdev/bitkit-ios#1277
Dependency: synonymdev/bitkit-core#195, released in 0.8.0-rc13

Adds the device side of the wake gateway: install credentials, a signed registration, wakes received from FCM data messages, acks, presence and unregister. Wakes stay off in any build without a gateway URL, so release builds behave as today until synonymdev/wake#2 deploys one.

Description

  • WakeService: wraps the core wake calls on ServiceQueue.CORE. Always FCM with the production environment, and the audience comes from wakeServerInfo.
  • Env.wakeGatewayUrl: null on every network. Only a debug build with WAKE_GATEWAY_URL set has one, through the debug-only URL fields in app/build.gradle.kts.
  • WakeCredentialStore: keeps the install id, key pair and device secret in the Keychain, plus the last registration timestamp and a hash of the last accepted registration. Nothing goes to VSS. Removes the orphaned PUSH_NOTIFICATION_PRIVATE_KEY entry.
  • WakeRepo: registers when the node runs, when the opt-in or the Pubky key changes, and from onNewToken. It skips when nothing changed.
    • The timestamp is max(now, last + 1), saved before sending. A 401 stale_timestamp signs again once at the error's serverTime.
    • ln: is proved with the new LightningRepo.signMessage. pk: is proved through PubkyRepo.signWakeRegistration, which keeps the secret inside PubkyRepo and reads a Ring key only when signing. While a saved identity cannot be read, the attempt is skipped rather than registering ln: alone.
    • Subscribes to paykit.* and swap.*. wake.test needs no subscription.
  • WakeConfig.isWakeOptedIn: registers only with Background Payments on and notification permission granted. Turning it off unregisters, and E2E builds never register.
  • FcmService, WakeWorker, WakeInbox: a data message goes to an expedited worker. The worker decrypts, skips a wake already handled, drops one past its deadline, runs the topic's handler and acks. Handled ids live in a new wake.json DataStore until their deadline. A failed ack is retried without redoing the work.
  • WakeHandlers: wake.test posts the topic's display text from wakeListTopics and acks HANDLED. Any other topic acks NO_HANDLER until the next phases add handlers.
  • Presence: set on ON_START, renewed every 60 s with a 90 s ttl, cleared on ON_STOP.
  • WipeWalletUseCase: unregisters before the Keychain wipe, best effort.
  • Dev Settings, debug only: a Wake section with the gateway URL, device status, Register Now and Send Test Wake.
  • FcmService no longer logs the FCM token, and AGENTS.md names the wake classes instead of WakeNodeWorker.

Out of Scope

Design

N/A: the only UI is the debug-only Dev Settings section.

Preview

N/A

QA Notes

Setup

  1. In synonymdev/wake, run docker compose -f docker/compose.yml --profile dev up -d --build --wait. The gateway listens on port 9010, with fake push providers that record pushes instead of delivering them.
  2. Build debug with WAKE_GATEWAY_URL=http://10.0.2.2:9010 (emulator) in local.properties or the environment.
  3. Turn on notifications and Background Payments, then open Dev Settings, Wake.

Real FCM delivery needs a gateway holding the service account of the Firebase project the build uses.

Journeys

N/A: no user-visible change in release builds.

Manual Tests

  • 1. With a gateway set and notifications on, tap Device → it shows ln:, plus pk: with a Pubky profile, and topics paykit.* and swap.*
  • 2. Tap Send Test Wake → a notification with the wake.test text appears, and wake status shows the wake acked: needs real FCM delivery
  • 3. Turn Background Payments off, then tap Device → the gateway answers 401, since the device was unregistered
  • 4. Wipe the wallet → the old device secret gets a 401

Automated Checks

  • added WakeInboxTest.kt: a wake delivered twice does its work once; a wake past its deadline is dropped; ack outcomes for handled, failed and no handler; a lost ack is sent again without the work; fallback, undecryptable and wakes-off pushes
  • added WakeRepoTest.kt: identity selection, waiting for the node and the Pubky key, skipping unchanged registrations, timestamps across launches and clock steps, the 401 retry, opt-in and opt-out, no work without a gateway or in E2E builds, unregister
  • added WakeConfigTest.kt: the opt-in gate
  • updated PubkyRepoTest.kt: pk: only with a key for the signed-in pubky, and a Ring key read just in time
  • updated WipeWalletUseCaseTest.kt: unregister runs before the Keychain wipe and a failure does not stop the wipe
  • updated AppViewModelSendFlowTest.kt: the new constructor parameter

Evidence

  • CI on 864b679e1, rebased onto master with bitkit-core 0.8.0-rc15: build, build-and-test (unit tests), lint, detekt and the compose UI tests passed.
  • ./gradlew detekt --rerun-tasks passed locally on the whole tree.
  • The wake classes and the 27 tests in the three new test files compiled and passed on the JVM. This ran outside Gradle, against Kotlin bindings generated from bitkit-core v0.8.0-rc13 and stubs of the app classes they use.
  • The core client calls were checked against the local gateway by core's live tests and by the iOS twin's live test.

Gaps

  • The app was not built locally, since this machine has no GitHub Packages credentials. CI compiled it and ran the unit tests.
  • No emulator or device run, and no real FCM delivery.

Open questions

  • Should alert topics without a handler post their display text, as feat: adopt the wake gateway for background pushes #1804 says?
  • Is the opt-in gate right? The prompt that turns it on still reaches only Paykit subscribers.
  • Should the app register again now and then, in case the gateway pruned or evicted the device?
  • PUSH_NOTIFICATION_TOKEN is unused too. Should it go?
  • Should the deployed gateway send channel_app, or should the app add a wake channel?

@github-actions

github-actions Bot commented Nov 10, 2026 •

Copy link
Copy Markdown
Contributor

Regtest APK

Built from ea85604 (run).

Download bitkit-dev-debug universal APK (expires in 30 days).

@coreyphillips
coreyphillips force-pushed the feat/wake-gateway-phase-1 branch from 16ace3c to 864b679 Compare November 12, 2026 15:43
@coreyphillips
coreyphillips marked this pull request as ready for review November 12, 2026 16:02
@greptile-apps

greptile-apps Bot commented Nov 12, 2026 •

Copy link
Copy Markdown
Contributor

RetriggerView in GreptileConfidence Score: 3/5

[Low impact] The wake flow is limited to builds that opt into a configured gateway.

Fix registration retries and the lost-response opt-out path before merging.

Findings

  1. P1 Failed registration changes stay unapplied ▶
  2. P1 Opt-out can leave devices registered ▶
  3. P2 Interrupted wakes can duplicate alerts ▶

Summary

Adds signed wake registration, FCM workers, saved acknowledgement state, foreground presence and debug controls. Wakes remain disabled without a debug gateway URL.

  • An opted-in install registers its wallet and push details with the wake gateway.
  • Incoming wake messages reach a handler and get acknowledged.
  • The gateway knows when the app is open and renews that status.
  • Debug settings show gateway status and offer wake test actions.
  • Wallet wipe asks the gateway to unregister the old install.

Scope acknowledged by coreyphillips: Paykit and swap handlers are deferred to later phases; display text for other alert topics and a separate notification channel are out of scope. Release gateway URLs intentionally remain unset.

Diagram

sequenceDiagram
    participant App
    participant Gateway
    participant FCM
    participant Worker
    participant Store
    App->>Gateway: Register signed identities and push token
    App->>Gateway: Renew presence while foregrounded
    Gateway->>FCM: Send encrypted wake
    FCM->>Worker: Enqueue data message
    Worker->>Store: Look up wake ID
    alt New wake before deadline
        Worker->>Worker: Run topic handler
        Worker->>Store: Save outcome
    end
    Worker->>Gateway: Acknowledge saved outcome
    Worker->>Store: Mark acknowledged
Loading

Reviews (1) · Last reviewed commit: "fix: start no wake work in builds withou..." · Reviewed by Greptile

Comment on lines +225 to +226
.distinctUntilChanged()
.collect { syncRegistration() }

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Failed registration changes stay unapplied

observeRegistration() consumes failed attempts without scheduling another try. If registration or unregister fails while the node, opt-in and Pubky key stay unchanged, the observer never retries. Returning to the app does not restart it either. This can leave an opted-in device unregistered, or an opted-out device registered, after the connection recovers. Retry failed attempts with a delay or when connectivity returns.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed: observeRegistration() collects with distinctUntilChanged and only logs a failed syncRegistration(). registrationJob also stays active across onAppBackground/onAppForeground, so a transient network failure is not retried until the process restarts or the push token changes. A small fix is to switch to collectLatest and retry failed syncs with capped backoff. A change of inputs cancels the loop, and a Waiting result counts as success, so it never loops. Not changed here: @greptile-apps cannot write to this repository, and checkin_trust is "write".

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed. distinctUntilChanged() means a transient registration or unregister failure is not retried while the inputs remain unchanged, and the still-active registrationJob prevents foregrounding from restarting the observer. collectLatest with capped backoff is a reasonable fix: input changes cancel the old retry loop, and Waiting should remain a non-retrying result. No code change was made here.

Tip: You can customize Greptile's behavior for this repo with .greptile/rules.md and .greptile/config.json.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed at WakeRepo.kt:219-226. The observer collects distinct inputs and syncRegistration() only logs a failure. registrationJob stays active across onAppBackground/onAppForeground, so a transient network error leaves an opted-in install unregistered (or an opted-out one registered) until the process restarts. The fix is a short retry loop inside collectLatest plus two backoff constants and a test, which fits this branch. Not changed here: @greptile-apps cannot write to this repository, and checkin_trust is "write".

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed. A retry loop inside collectLatest with capped backoff is the right fix: retries continue while the inputs are unchanged, and an input change cancels the old loop. Waiting should remain non-retryable. Please include a test showing a transient sync failure is retried and then succeeds without an input change.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in a46f3b0: a transient failure (no answer, 5xx, 429) retries from 30 s, doubling to 15 min or the gateway's Retry-After; an input change cancels it, and Waiting and other 4xx are not retried.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Observer retries now pass, including transient failure and Retry-After backoff. The token callback remains outside that loop: FcmService.onNewToken calls syncRegistrationAsync(token), which performs one sync, and the observer does not observe token changes. With the observer settled, my added regression records one failed callback attempt and no retry after 31 seconds of virtual time. This part remains unresolved at ea85604.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You’re right; this remains a real gap. syncUntilSettled() retries failures from the registration observer, but FcmService.onNewToken() calls syncRegistrationAsync(token), which runs only one attempt. Since the FCM token is not part of the observer’s combine, a transient failure there won’t be retried unless another observed input changes or another token callback arrives. The regression test correctly demonstrates this. Route token changes through the same retryable path, while ensuring a newer token supersedes retries for an older one.

@coreyphillips

Copy link
Copy Markdown
Contributor Author

Answered

  • @greptile-apps on app/src/main/java/to/bitkit/repositories/WakeRepo.kt:226: Confirmed: observeRegistration() collects with distinctUntilChanged and only logs a failed syncRegistration(). registrationJob also stays active across onAppBackground/onAppForeground, so a transient network failure is not retried until the process restarts or the push token changes. A small fix is to switch to collectLatest and retry failed syncs with capped backoff. A change of inputs cancels the loop, and a Waiting result counts as success, so it never loops.
  • @greptile-apps on app/src/main/java/to/bitkit/repositories/WakeRepo.kt:206: Confirmed: loadOrCreate() saves the credentials before the first register(). fingerprint is written only after the response arrives, so a lost response leaves it null and optOut() at line 206 returns NotOptedIn without telling the gateway. Removing that check would call unregister on every opted-out launch. A cheaper fix is a flag in WakeRegistrationState: set it in registerAt next to lastTimestampSecs, clear it in unregisterDevice, and have optOut() check the flag.
  • @greptile-apps on app/src/main/java/to/bitkit/fcm/WakeHandlers.kt:67: Confirmed: WakeAlertHandler calls pushNotification with the default Random.nextInt() id, and WakeInbox.process saves the wake as received only after the handler returns. A failed saveReceived or a process death in that window makes WorkManager rerun the handler and post a duplicate. Passing id = wake.id.hashCode() is a one-line fix, since the inbox only reaches handlers with a non-null id.

@greptile-apps

greptile-apps Bot commented Nov 12, 2026

Copy link
Copy Markdown
Contributor

Confirmed—all three findings are valid:

  • Registration failures are only logged, and the distinct input flow does not retry unless an input changes. Retrying failed syncs with capped backoff, while treating Waiting as a completed attempt, would address this.
  • A lost registration response can leave the gateway registered while the local fingerprint remains null, so opt-out skips unregistering. Persisting a registration-attempted flag before the request and clearing it on unregister closes that gap without unregistering every opted-out launch.
  • A process interruption after the notification is posted but before the wake is saved can cause the handler to post it again. Using the non-null wake ID as the notification ID makes retries update the same notification instead of creating another.

@coreyphillips

Copy link
Copy Markdown
Contributor Author

Answered

  • @greptile-apps on app/src/main/java/to/bitkit/repositories/WakeRepo.kt:226: Confirmed at WakeRepo.kt:219-226. The observer collects distinct inputs and syncRegistration() only logs a failure. registrationJob stays active across onAppBackground/onAppForeground, so a transient network error leaves an opted-in install unregistered (or an opted-out one registered) until the process restarts. The fix is a short retry loop inside collectLatest plus two backoff constants and a test, which fits this branch.
  • @greptile-apps on app/src/main/java/to/bitkit/repositories/WakeRepo.kt:206: Confirmed. loadOrCreate() saves credentials before the first send, but fingerprint is written only after the response (WakeRepo.kt:160). If the response is lost, optOut() at line 206 returns NotOptedIn while the gateway still holds the device and its paykit.* and swap.* fallback alerts. A flag avoids an unregister call on every opted-out launch. The change is one field, two writes, one check and a regression test.
  • @greptile-apps on app/src/main/java/to/bitkit/fcm/WakeHandlers.kt:67: Confirmed. pushNotification defaults to Random.nextInt() (Notifications.kt:80). WakeInbox.process saves the wake as received only after the handler returns (WakeInbox.kt:89-91). A process death or a failed saveReceived in that window means WakeWorker's retry posts a duplicate. The inbox only reaches handlers with a non-null id, so this is a one-line fix.

@greptile-apps

greptile-apps Bot commented Nov 12, 2026

Copy link
Copy Markdown
Contributor

Yes—this accurately summarizes the three confirmed findings and the scoped fixes: retry failed registration syncs with capped backoff, persist whether registration was attempted so opt-out can unregister after a lost response, and use the wake ID as the notification ID to make alert retries idempotent.

@talosmachina talosmachina left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No new findings. Adds the device side of the wake gateway: install credentials in the Keychain, a signed ln:/pk: registration, FCM data wakes handled once by an expedited worker and acked, a presence lease while the app is open, and unregister on opt-out and wipe. Reviewed 864b679e1, oversized tier by line count (1,826 lines). Every production file was read whole or as a full diff. The five test files were only skimmed.

Two P1s that Greptile raised at this head are still open, and @coreyphillips agreed with both in their threads. I am not posting them again:

  • observeRegistration() swallows a failed sync, and distinctUntilChanged plus the registrationJob that stays alive mean nothing retries it until an input changes.
  • optOut() returns NotOptedIn when the first registration's response was lost. loadOrCreate() saved the credentials, but fingerprint was never written, so the gateway keeps a device the app thinks it never registered.
What I checked, and 7 candidates I ruled out

Read in full: WakeRepo.kt, WakeService.kt, WakeCredentialStore.kt, WakeStore.kt, WakeStoreSerializer.kt, WakeInbox.kt, WakeWorker.kt, WakeHandlers.kt, WakeConfig.kt. Full diffs of FcmService.kt, PubkyRepo.kt, PubkyService.kt, LightningRepo.kt, Env.kt, Keychain.kt, WipeWalletUseCase.kt, ContentView.kt, AppViewModel.kt, DevSettingsViewModel.kt, DevSettingsScreen.kt, build.gradle.kts.
Call sites traced: syncRegistration, unregister, awaitIdentityReady, ringCredential, setNotificationPreference (the source of the opt-in), Keychain.wipe.
Build: not built here. An auto review never builds a Bitkit Android branch, so this review is reasoned from the code and from CI.
CI: build, build-and-test, lint, detekt and Greptile are green. compose-ui and the e2e shards were still pending when I read them.

Ruled out

  • Re-registration during the wipe: stopNode() runs before cleanupRemote(), so getNodeId() returns null and a sync running alongside returns Waiting. unregister() takes the same registrationMutex.
  • Wipe fails after unregister: when the node starts again, the observed triple changes and the sync registers again because fingerprint is null.
  • Dropped PUSH_NOTIFICATION_PRIVATE_KEY enum entry: no code decodes stored keychain names back through Key.valueOf, so the stored orphan is never read.
  • Release builds start wake work: WAKE_GATEWAY_URL is in the debug-only field list, Env.wakeGatewayUrl also gates on isDebug, and every entry point returns early on a null URL.
  • Ack retry repeats the work: WakeInbox saves the outcome before acking. A redelivery with acked = false re-sends the stored outcome without calling the handler again.
  • Clear presence races with a new foreground: clearPresence() checks presenceJob?.isActive under presenceMutex, so a quick return to the foreground keeps the lease.
  • Revoking the notification permission in system settings does not opt out: MainActivity writes areNotificationsEnabled() back into notificationsGranted on resume, and optedIn emits again from that change.

Merge confidence: 2/5, the two open P1s above (both confirmed by the author, neither fixed at this head). Without them, the oversized tier would cap it at 3.

Reviewer model: claude-opus-5-5
Reasoning effort: Not exposed (the run metadata available to this review does not carry a reasoning setting)

@jvsena42 jvsena42 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One LOW inline, approving. Everything below is dev-gated today: Env.wakeGatewayUrl is null outside debug builds with WAKE_GATEWAY_URL.

Checked and clean:

  • No funds path: wake.test posts a local notification, everything else acks NO_HANDLER, and WakeWorker gets only WakeInbox, with no LightningRepo.
  • Payload values stay inert: none reaches a URL, amount or wallet selector; payload_json is unused, and acks go to the build-configured gateway.
  • No seed-derived secret leaves the device: registration sends the device token, install id, wake encryption public key, SHA-256 of the device secret, ln: and pk:, and the push token is no longer logged.
  • Bounded signing: the signed preimage is the fixed wake-register-v1 form, and core rejects newline and comma injection, so the gateway can't get an arbitrary message signed. The timestamp is max(now, last + 1) and saved before sending.
  • Wipe: the node stops first, unregister() and syncRegistration() share registrationMutex, and the wipe deletes the FCM token.
  • Overflow: the iOS twin's gateway-time overflow doesn't apply here, because Long math wraps and a negative deadline lands in Stale.

The LOW can be fixed here or moved to a follow-up issue, your choice; please say which on the thread.

⚠️ Device gate: not run — manual tests 1-4 need the local wake gateway, which is not available on my setup. This approval rests on the code scan and unit tests. perf: a 60 s presence loop while foregrounded and one expedited worker per push, both behind the gateway URL; worth measuring before a URL ships. CI green at 864b679e1.

Reviewer model: Claude Fable 5.2 (claude-fable-5-2), Claude Opus 5.5 (claude-opus-5-5)
Reasoning effort: not exposed

@ovi-reviewer ovi-reviewer Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verdict: ⛔️ Request changes — both added recovery regressions fail at 864b679.

Findings:

  • MEDIUM — Invalidate cached registration content before an uncertain update (app/src/main/java/to/bitkit/repositories/WakeRepo.kt:159). Start with a successful ln: registration, add pk:, let the gateway accept the update but lose its response, then sign out. The retained ln: fingerprint makes the next sync return UpToDate, leaving the signed-out Pubky identity registered remotely. Invalidate or mark the content dirty before sending an update, while retaining the timestamp. The added regression reproduces this against unchanged production sources.
  • MEDIUM — Recheck foreground state before a delayed presence refresh (app/src/main/java/to/bitkit/repositories/WakeRepo.kt:161). The registration-triggered refresh is a separate job. It can run after backgrounding cancels the presence loop and finishes clearing the lease, then recreate a 90-second lease. The gateway suppresses only_if_offline wakes during that lease. Check foreground state under the presence mutex or keep the refresh within the lifecycle job. A controlled scheduling regression reproduces the late refresh after clear.

Existing findings remain open at this head:

I have not duplicated these inline comments.

QA for 864b679:

Debug regtest build, Android 16 emulator, local wake gateway with fake push providers. I built the original head first. The subsequent device build adds only a local debug provider hook to supply a synthetic FCM token and enqueue recorded gateway payloads; production sources are unchanged. Videos are 2× speed.

Register the wallet and inspect its identities ✅ Gateway API responses confirmed both identities and the expected topic subscriptions.

Case 1. The first registration used ln: alone; creating a profile added pk:. Device displayed both identities and paykit.*, swap.*. Gateway responses confirmed the complete values. The token was supplied through the debug hook because the checked-in Firebase configuration is a placeholder.

Lightning registration Pubky profile registration
registration-live-2x.2x.mp4
profile-registration-2x.2x.mp4
Receive and acknowledge the test alert through FCM ⏭️ Real FCM transport was unavailable.

Case 2. Send Test Wake queued an encrypted message through the fake provider. The Firebase configuration is a placeholder, and no matching real Firebase service account was available. The worker injection below covers receipt and acknowledgement after transport, without establishing real FCM delivery or its service callback.

receive-test-wake-2x.2x.mp4

Send Test Wake queued the payload; delivery later in this clip uses worker injection.

Disable Background Payments and revoke the device ✅ Device and the retained old credential returned HTTP 401 after opt-out.

Case 3. Disabled notifications through the Background Payments setting, returned to Bitkit, and refreshed Device. The gateway API independently confirmed revocation.

opt-out-2x.2x.mp4

Disabling notifications and refreshing Device.

Wipe the wallet and revoke its old device ✅ Reset reached onboarding and the old credential returned HTTP 401, checked through the gateway API.

Case 4. I re-enabled notifications and confirmed registration before resetting. The screenshot captures onboarding after the recording ended during cleanup.

Reset Completed reset
wipe-wallet-2x.2x.mp4

Resetting the registered test wallet and its final onboarding screen.

Decrypt and acknowledge a gateway-recorded push ✅ Injected foreground and background alerts displayed; gateway records confirmed handled acknowledgements.

Additional case 7. I injected the fake provider's encrypted data map at WakeWorker.enqueue. The notification showed “Wake test” and “Test notification from the wake gateway”; the gateway recorded acked / handled. Repeating the first payload returned Duplicate and left one alert for that wake. The background run used a separate wake, so its second alert was expected. WorkManager logs and the gateway's acknowledgement records establish these results.

Foreground receipt and duplicate Background receipt
receive-test-wake-2x.2x.mp4
receive-background-2x.2x.mp4
Renew foreground presence and clear it in the background ✅ The expiry advanced by 60 seconds, then became absent after backgrounding, checked through the device API.

Additional case 5. Device was refreshed before and after the renewal. This normal lifecycle path passed; the delayed refresh regression below exercises the separate scheduling race.

presence-lifecycle-2x.2x.mp4

Refreshing Device, then backgrounding the app.

Recover registration and presence after interrupted operations ❌ Both added Gradle regressions failed against unchanged production sources.

Additional case 6. The uncertain registration update returned UpToDate while the simulated gateway retained pk:. The controlled dispatcher ran a registration-triggered presence refresh after background clearing and observed the lease restored. The original focused unit suites passed before these reproductions were added. The video replays the captured Gradle console output; assertions establish the failures.

regressions-2x.2x.mp4

Timed replay of the actual regression output.

Twin: synonymdev/bitkit-ios#1277 matches the phase 1 registration, subscription and presence design. Android receives through WorkManager and retries stored acknowledgement outcomes.

Coverage: full 2f18eb6..864b679, all changed files and reached node lifecycle, Pubky identity, notification, wipe and queue code. Original app build and focused unit suites passed. CI lint and detekt are green; I did not rerun lint locally. Release gating, timestamp recovery, Ring identity, expiry and ack retries were checked in code or unit tests without separate device journeys. Real FCM delivery and its service callback remain unverified. Swap and Paykit handlers remain outside this phase.


Reviewed by gpt-6.1-sol-high

…s lost

- A transient failure (no answer, 5xx, 429) is retried with capped backoff until the inputs change.
- The fingerprint is cleared and a may-be-registered flag set before each send, so a lost answer is resent and opt-out still unregisters.
A registration asks the loop to renew instead of launching its own renewal, so leaving the app cancels a pending one.
A retried delivery replaces the alert instead of posting a second one.
The Ring credential is read only when signing.

@ovi-reviewer ovi-reviewer Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verdict: ⛔️ Request changes — background presence cleanup fails after an uncertain registration update; token-callback retries remain incomplete.

Findings:

  • MEDIUM — Clear existing presence while registration content is uncertain (app/src/main/java/to/bitkit/repositories/WakeRepo.kt:219). Clearing the fingerprint before an update also makes registeredDevice() return null. If an already registered app loses an update response, then backgrounds, clearPresence() skips the existing lease. The gateway keeps suppressing only_if_offline wakes until its 90-second expiry. I reproduced this on the emulator against the gateway and in a focused regression. Clear the lease using retained credentials or mayBeRegistered, independently of the accepted-content fingerprint.

The existing retry finding is partially unresolved: observer retries work, but onNewToken still calls the single-attempt syncRegistrationAsync(token). With the observer settled, the added callback regression makes one failed attempt and no retry after 31 seconds of virtual time. I have not duplicated it inline.

The uncertain-content regression now passes, as does the queued presence-renewal regression. Other verified fixes:

QA for ea85604:

Debug regtest build, Android 16 emulator, local wake gateway with fake push providers. I built the original head and ran the focused wake, Pubky and wipe suites successfully. Device checks use a debug-only provider hook for a synthetic FCM token, recorded-payload injection and direct alert-handler replay; production sources are unchanged. Videos are 2× speed.

Register the wallet and inspect its identities ✅ Gateway API responses confirmed real Lightning and Pubky proofs with the expected subscriptions.

Case 1. Registration first used ln: alone. Creating a Pubky profile added pk:; Device displayed both identities and paykit.*, swap.*. The token was supplied through the debug hook.

Lightning registration Pubky registration
registration-2x.2x.mp4
profile-complete-2x.2x.mp4

Device registration before and after profile creation.

Receive and acknowledge the test alert through FCM ⏭️ Real FCM transport remains unverified.

Case 2. The checked-in Firebase configuration is a placeholder; token acquisition in the running app failed with an invalid API key error. No matching real Firebase service account was available. Send Test Wake queued encrypted data through the fake provider. Case 7 below covers the downstream worker and handler, bypassing FCM transport and the FcmService callback.

receive-2x.2x.mp4

Send Test Wake queues the payload; subsequent delivery in this recording uses injection.

Disable Background Payments and revoke the device ✅ Device and the retained old credential returned HTTP 401 after opt-out, checked through the gateway API.

Case 3. Disabled notifications from the Background Payments/Notifications setting and refreshed Device after returning to Bitkit.

opt-out-2x.2x.mp4

Notification opt-out and the revoked Device response.

Wipe the wallet and revoke its old device ✅ Reset reached onboarding and the retained credential returned HTTP 401, checked through the gateway API.

Case 4. Re-enabled notifications and confirmed registration before resetting.

wipe-wallet-2x.2x.mp4

Resetting the registered test wallet and returning to onboarding.

Renew foreground presence and clear it in the background ✅ The lease renewed, then cleared after Home, checked through the device API.

Case 5. The gateway expiry advanced by 61 seconds between samples, then became null after backgrounding.

presence-2x.2x.mp4

Refreshing Device before and after renewal, then backgrounding.

Recover registration and presence after interrupted operations ❌ The token callback still receives no retry after a transient failure.

Case 6. The original accepted-update/lost-response reproduction now reconciles ln: after removing pk:. The queued-renewal/background regression also passes. The added token-callback regression fails: the observer had settled, the callback's first request failed, and no second request ran after 31 seconds of virtual time. Production sources are unchanged; Gradle assertions establish the result.

regressions-2x.2x.mp4

Replay of the captured JUnit results, including the separate presence-cleanup failure below.

Decrypt and acknowledge a gateway-recorded push ✅ Injected foreground and background alerts displayed; gateway records confirmed handled acknowledgements.

Case 7. Injected the fake provider's encrypted data map at WakeWorker.enqueue. The notification displayed “Wake test” and “Test notification from the wake gateway”. A repeat returned Duplicate. Directly replaying WakeAlertHandler for the same decrypted wake left one active notification with the same ID, checked through Android notification records. A separate background wake also displayed and acked handled; its second alert was expected.

receive-2x.2x.mp4

Gateway queueing, injected worker receipt, repeated handler and separate background receipt.

Clear presence after a failed registration update ❌ Backgrounding retained the existing lease, confirmed through the gateway API.

Case 8. With a live foreground lease, a proxy forwarded a registration update and received HTTP 200, then returned HTTP 503 to the app. After Home, the device API still reported the same unexpired presence lease. The normal background case above cleared it. The added Gradle regression independently fails because clearPresence is never called.

uncertain-presence-2x.2x.mp4

Device inspection and backgrounding after the simulated lost update response; API responses establish the retained lease.

Debug test hook

Not pushed. The provider supplies a synthetic push token, enqueues the gateway's recorded encrypted data map, and directly replays the production alert handler. It also saves the test device credential privately for independent revocation checks.

diff --git a/app/src/debug/java/to/bitkit/dev/DevToolsProvider.kt b/app/src/debug/java/to/bitkit/dev/DevToolsProvider.kt
index 6532aa144..3706b62dc 100644
--- a/app/src/debug/java/to/bitkit/dev/DevToolsProvider.kt
+++ b/app/src/debug/java/to/bitkit/dev/DevToolsProvider.kt
@@ -32,6 +32,10 @@ class DevToolsProvider : ContentProvider() {
     @InstallIn(SingletonComponent::class)
     interface Dependencies {
         fun lightningRepo(): LightningRepo
+        fun wakeRepo(): to.bitkit.repositories.WakeRepo
+        fun wakeCredentialStore(): to.bitkit.data.WakeCredentialStore
+        fun wakeService(): to.bitkit.services.WakeService
+        fun wakeAlertHandler(): to.bitkit.fcm.WakeAlertHandler
     }
 
     private val deps: Dependencies by lazy {
@@ -41,6 +45,33 @@ class DevToolsProvider : ContentProvider() {
 
     override fun call(method: String, arg: String?, extras: Bundle?): Bundle {
         check(Binder.getCallingUid() == SHELL_UID) { "Only ADB shell callers are allowed" }
+        if (method.startsWith("qaWake")) {
+            val result = kotlinx.coroutines.runBlocking(kotlinx.coroutines.Dispatchers.IO) {
+                runCatching {
+                    when (method) {
+                        "qaWakeRegister" -> deps.wakeRepo().syncRegistration(pushToken = requireNotNull(arg), force = true).getOrThrow().toString()
+                        "qaWakeDevice" -> deps.wakeRepo().getDevice().getOrThrow().toString()
+                        "qaWakeSaveCredential" -> {
+                            val secret = checkNotNull(deps.wakeCredentialStore().load()).deviceSecret
+                            java.io.File(requireNotNull(context).noBackupFilesDir, "qa-wake-secret").writeText(secret)
+                            "saved test credential"
+                        }
+                        "qaWakeHandleAgain" -> {
+                            val keys = checkNotNull(deps.wakeCredentialStore().load())
+                            val wake = deps.wakeService().decryptPush(keys.encryptionSecretKey, requireNotNull(arg))
+                            deps.wakeAlertHandler().handle(wake)
+                            "replayed alert handler"
+                        }
+                        "qaWakeReceive" -> {
+                            to.bitkit.fcm.WakeWorker.enqueue(requireNotNull(context), requireNotNull(arg))
+                            "enqueued recorded gateway payload"
+                        }
+                        else -> error("Unknown QA method")
+                    }
+                }.fold(onSuccess = { it }, onFailure = { "ERROR: ${it.message}" })
+            }
+            return Bundle().apply { putString("result", result) }
+        }
         return runCatching {
             val command = requireNotNull(DevCommand.parse(method, arg)) { "Unknown command: '$method'" }
             ServiceQueue.LDK.blocking { command.execute(deps) }

Deciding worker log excerpts:

Received wake push 'Acked(outcome=HANDLED)'
Received wake push 'Duplicate'
Received wake push 'Acked(outcome=HANDLED)'

The fault proxy preserves the registration request and gateway acceptance, then replaces one response with HTTP 503 to stage case 8.

Twin:

Coverage: delta 864b679..ea85604, all seven changed files and the reached registration, presence, signing, notification and worker paths, plus the six earlier findings. The previous full review supplies the earlier context; affected device cases were rerun here. Ring identity, lost-first-response opt-out, backoff and cancellation were checked through code and focused unit tests without dedicated device journeys. Lint was not rerun locally. Real FCM delivery remains unverified. Swap and Paykit handlers remain outside this phase.


Reviewed by gpt-6.1-sol-high

// The gateway may store this registration even when its answer is lost, so until an answer says otherwise
// the last accepted content is unknown and an opt-out has something to unregister.
credentialStore.updateRegistrationState {
it.copy(lastTimestampSecs = timestampSecs, fingerprint = null, mayBeRegistered = true)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

MEDIUM — Clear existing presence even while registration content is uncertain

Clearing the accepted-content fingerprint before an update also makes registeredDevice() return null. Start with a registered foreground app and an active presence lease, let the gateway accept an update but lose its response, then background the app. clearPresence() skips the gateway call because the fingerprint is null, leaving the lease active and suppressing only_if_offline wakes until its 90-second expiry. The device reproduction retained the same lease after Home; a focused regression also confirms that clearPresence is never called. Clear an existing lease using the retained credentials or mayBeRegistered, independently of whether the registration content is known.

This branch has not been deployed

No deployments
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.

3 participants