Mobile: register the device push token after login (#87) - #106
Merged
Conversation
Adds the last missing Refit client: `IDevicesApi` over the existing `POST /api/v1/devices`, registered through `AuthDelegatingHandler` like every other authenticated client. `LoginViewModel` posts the device token after a successful login. Registration is best-effort by design. It runs after `LoginSucceeded` is raised so navigation is never delayed, a null/blank token (permission refused) posts nothing, and every exception from either the token provider or the API call is swallowed - a user who cannot receive notifications must still be able to use the app. The backend upsert on (user_id, token) makes repeat calls harmless. The token itself comes from a new `IPushTokenProvider` seam, mirroring how `IConnectivityService` and `ITokenStore` wrap platform APIs. Its only implementation for now is `UnavailablePushTokenProvider`, which always returns null: the real Firebase-backed one needs the messaging SDK from issue #86, which is blocked on a human provisioning the Firebase project and its google-services.json / GoogleService-Info.plist. That null path is the same one a permission refusal takes, so the flow around it is fully exercised today. Issue #87 stays open - its remaining acceptance criteria need a real token. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This was referenced Aug 6, 2026
rghvgrv
added a commit
that referenced
this pull request
Aug 6, 2026
) The reminder is "Netflix renews in 3 days". The app already knows that: it has the billing date and the lead time in its local mirror, weeks ahead of time. Sending it from a server meant a Firebase project, a service-account key in a secret-scanned repo, platform config files, a device-token table and an FCM send path - all to deliver a fact the client already had. A scheduled local notification appears on the lock screen with the app closed, exactly like a pushed one. The app is only needed to decide the schedule, not to display it. RenewalNotificationPlanner is pure and holds the rules that decide whether a user is reminded, which the platform call around it cannot test: active subscriptions only, notify at next_billing_date - alert_days_advance at 09:00 local, drop anything already past, soonest first, capped at 64 because iOS holds no more pending notifications and silently drops the rest. Every sync cancels and re-derives rather than diffing. An added subscription, an edited date, a changed lead time and a deletion all have to change the pending set, and re-deriving 64 entries is far less code than working out which happened. What this deletes: FcmPushNotificationSender, DevicesController, DeviceTokenRepository, IPushNotificationSender, the device_tokens and notifications_log tables, the mobile device-registration path added in #106, and the Firebase dependency entirely. Issues #86 and #87 go with it. What survives, deliberately: the nightly job. RenewalAlertBackgroundService was doing two unrelated jobs, and only one of them was notifications. Rolling passed billing dates forward and retiring OneTime subscriptions is data maintenance the client cannot do, so it stays as BillingDateAdvanceBackgroundService with the alert half removed. notifications_log went with the push path because its only purpose was deduplicating sends; advancement is idempotent by construction, since a second pass finds nothing left in the past. Known limit, marked in code: scheduling refreshes when the subscription list screen loads. That covers add, edit and delete, since navigation returns there. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
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 the client-side wiring for #87 — everything that does not require a real FCM token.
What changed
POST /api/v1/devicesshipped in #63 and has never been called.DevicesControllerwas the only backend controller without a Refit interface on the mobile side, soRenewalAlertBackgroundServicelooks up device tokens on every due subscription and finds zero, every time.IDevicesApi—[Post("/api/v1/devices")], registered inMauiProgramthroughAuthDelegatingHandlerlike every other authenticated client. The response body is the storeddevice_tokensrow and is of no use to the client, so the method returns bareTaskand a non-success status surfaces as theApiExceptionthe caller already swallows.RegisterDeviceTokenRequestinApi/Dtos/mirroring the backend's contract by convention (no shared DTO project, per the existing decision).IPushTokenProvider— the platform seam, alongsideIConnectivityServiceandITokenStore. ExposesPlatform("Android"/"iOS", exactly whatRegisterDeviceTokenRequestValidatoraccepts) andGetTokenAsync().LoginViewModelposts the token after a successful login.Why registration is best-effort
A user who cannot receive notifications must still be able to use the app, so:
LoginSucceededis raised, so navigation is never delayed by a slow or hanging network call;The backend upsert on
(user_id, token)makes repeat calls harmless, which is what lets this be fire-and-forget without a dedup guard on the client.The provider implementation is a placeholder
UnavailablePushTokenProvideralways returns null. The real Firebase-backed one needs the messaging SDK from #86, which is blocked at its own step 1: a human must provision the Firebase project and supplygoogle-services.json/GoogleService-Info.plist, and decide whether those files are committed given thedetect-secretshard stop.I deliberately did not add a Firebase binding package here. It cannot be built or run without those config files, so adding it would mean committing an unverifiable vendor dependency — the package choice belongs in #86 alongside the config it needs.
The null path is the same one a permission refusal takes, so the flow around the seam is fully exercised today. When #86 lands, the only change here is the one DI line in
MauiProgram.Tests
tests/SubVora.Mobile.Tests/LoginViewModelTests.cs, with new hand-writtenFakeDevicesApiandFakePushTokenProvidermatching the existing fake style:IDevicesApidoes not fail the login;IPushTokenProviderdoes not fail the login.dotnet test tests/SubVora.Mobile.Tests/SubVora.Mobile.Tests.csproj -c Release→ 78 passed, 0 failed.The Android TFM was not built locally (no Android SDK on this machine); CI does not build it either — the mobile job runs the Windows TFM on
windows-latest.Still open
#87 stays open. Two of its three acceptance criteria need a real token on a real device:
last_seen_atrather than duplicating the rowBoth unblock the moment #86 does.