Repository navigation
feat: register with the wake gateway and receive wakes - #1847
coreyphillips wants to merge 13 commits into
Conversation
Regtest APKDownload bitkit-dev-debug universal APK (expires in 30 days). |
16ace3c to
864b679
Compare
[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
SummaryAdds signed wake registration, FCM workers, saved acknowledgement state, foreground presence and debug controls. Wakes remain disabled without a debug gateway URL.
Scope acknowledged by DiagramsequenceDiagram
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
Reviews (1) · Last reviewed commit: "fix: start no wake work in builds withou..." · Reviewed by Greptile |
| .distinctUntilChanged() | ||
| .collect { syncRegistration() } |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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".
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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".
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
|
Answered
|
|
Confirmed—all three findings are valid:
|
|
Answered
|
|
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
left a comment
There was a problem hiding this comment.
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, anddistinctUntilChangedplus theregistrationJobthat stays alive mean nothing retries it until an input changes.optOut()returnsNotOptedInwhen the first registration's response was lost.loadOrCreate()saved the credentials, butfingerprintwas 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 beforecleanupRemote(), sogetNodeId()returns null and a sync running alongside returnsWaiting.unregister()takes the sameregistrationMutex. - Wipe fails after unregister: when the node starts again, the observed triple changes and the sync registers again because
fingerprintis null. - Dropped
PUSH_NOTIFICATION_PRIVATE_KEYenum entry: no code decodes stored keychain names back throughKey.valueOf, so the stored orphan is never read. - Release builds start wake work:
WAKE_GATEWAY_URLis in the debug-only field list,Env.wakeGatewayUrlalso gates onisDebug, and every entry point returns early on a null URL. - Ack retry repeats the work:
WakeInboxsaves the outcome before acking. A redelivery withacked = falsere-sends the stored outcome without calling the handler again. - Clear presence races with a new foreground:
clearPresence()checkspresenceJob?.isActiveunderpresenceMutex, so a quick return to the foreground keeps the lease. - Revoking the notification permission in system settings does not opt out:
MainActivitywritesareNotificationsEnabled()back intonotificationsGrantedon resume, andoptedInemits 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
left a comment
There was a problem hiding this comment.
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.testposts a local notification, everything else acksNO_HANDLER, andWakeWorkergets onlyWakeInbox, with noLightningRepo. - Payload values stay inert: none reaches a URL, amount or wallet selector;
payload_jsonis 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:andpk:, and the push token is no longer logged. - Bounded signing: the signed preimage is the fixed
wake-register-v1form, and core rejects newline and comma injection, so the gateway can't get an arbitrary message signed. The timestamp ismax(now, last + 1)and saved before sending. - Wipe: the node stops first,
unregister()andsyncRegistration()shareregistrationMutex, and the wipe deletes the FCM token. - Overflow: the iOS twin's gateway-time overflow doesn't apply here, because
Longmath wraps and a negative deadline lands inStale.
The LOW can be fixed here or moved to a follow-up issue, your choice; please say which on the thread.
864b679e1.
Reviewer model: Claude Fable 5.2 (claude-fable-5-2), Claude Opus 5.5 (claude-opus-5-5)
Reasoning effort: not exposed
There was a problem hiding this comment.
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 successfulln:registration, addpk:, let the gateway accept the update but lose its response, then sign out. The retainedln:fingerprint makes the next sync returnUpToDate, 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 suppressesonly_if_offlinewakes 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:
- Registration retry.
- Opt-out after a lost first response.
- Duplicate alerts after interrupted handling.
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.
There was a problem hiding this comment.
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 makesregisteredDevice()return null. If an already registered app loses an update response, then backgrounds,clearPresence()skips the existing lease. The gateway keeps suppressingonly_if_offlinewakes 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 ormayBeRegistered, 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:
- bitkit-ios#1277 feat: register with the wake gateway and receive wakes matches the phase 1 registration, subscriptions and presence design. Its registration task observes token changes; Android's callback remains outside its retry loop.
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) |
There was a problem hiding this comment.
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.

Part of #1804 (Phase 1)
Twin: synonymdev/bitkit-ios#1277
Dependency: synonymdev/bitkit-core#195, released in
0.8.0-rc13Adds 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 onServiceQueue.CORE. Always FCM with the production environment, and the audience comes fromwakeServerInfo.Env.wakeGatewayUrl: null on every network. Only a debug build withWAKE_GATEWAY_URLset has one, through the debug-only URL fields inapp/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 orphanedPUSH_NOTIFICATION_PRIVATE_KEYentry.WakeRepo: registers when the node runs, when the opt-in or the Pubky key changes, and fromonNewToken. It skips when nothing changed.max(now, last + 1), saved before sending. A 401stale_timestampsigns again once at the error'sserverTime.ln:is proved with the newLightningRepo.signMessage.pk:is proved throughPubkyRepo.signWakeRegistration, which keeps the secret insidePubkyRepoand reads a Ring key only when signing. While a saved identity cannot be read, the attempt is skipped rather than registeringln:alone.paykit.*andswap.*.wake.testneeds 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 newwake.jsonDataStore until their deadline. A failed ack is retried without redoing the work.WakeHandlers:wake.testposts the topic's display text fromwakeListTopicsand acksHANDLED. Any other topic acksNO_HANDLERuntil the next phases add handlers.ON_START, renewed every 60 s with a 90 s ttl, cleared onON_STOP.WipeWalletUseCase: unregisters before the Keychain wipe, best effort.FcmServiceno longer logs the FCM token, andAGENTS.mdnames the wake classes instead ofWakeNodeWorker.Out of Scope
wake.test.wakechannel. Instead of adding one,CHANNEL_MAINdocuments that the gateway must sendchannel_app.Design
N/A: the only UI is the debug-only Dev Settings section.
Preview
N/A
QA Notes
Setup
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.WAKE_GATEWAY_URL=http://10.0.2.2:9010(emulator) inlocal.propertiesor the environment.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
ln:, pluspk:with a Pubky profile, and topicspaykit.*andswap.*wake.testtext appears, andwake statusshows the wake acked: needs real FCM deliveryAutomated Checks
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 pushesWakeRepoTest.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, unregisterWakeConfigTest.kt: the opt-in gatePubkyRepoTest.kt:pk:only with a key for the signed-in pubky, and a Ring key read just in timeWipeWalletUseCaseTest.kt: unregister runs before the Keychain wipe and a failure does not stop the wipeAppViewModelSendFlowTest.kt: the new constructor parameterEvidence
864b679e1, rebased onto master with bitkit-core0.8.0-rc15: build, build-and-test (unit tests), lint, detekt and the compose UI tests passed../gradlew detekt --rerun-taskspassed locally on the whole tree.v0.8.0-rc13and stubs of the app classes they use.Gaps
Open questions
PUSH_NOTIFICATION_TOKENis unused too. Should it go?channel_app, or should the app add awakechannel?