Skip to content

Mobile: register the device push token after login (#87) - #106

Merged
rghvgrv merged 1 commit into
mainfrom
feat/issue-87-device-token-registration
Aug 6, 2026
Merged

Mobile: register the device push token after login (#87)#106
rghvgrv merged 1 commit into
mainfrom
feat/issue-87-device-token-registration

Conversation

@rghvgrv

@rghvgrv rghvgrv commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

Closes the client-side wiring for #87 — everything that does not require a real FCM token.

What changed

POST /api/v1/devices shipped in #63 and has never been called. DevicesController was the only backend controller without a Refit interface on the mobile side, so RenewalAlertBackgroundService looks up device tokens on every due subscription and finds zero, every time.

  • IDevicesApi[Post("/api/v1/devices")], registered in MauiProgram through AuthDelegatingHandler like every other authenticated client. The response body is the stored device_tokens row and is of no use to the client, so the method returns bare Task and a non-success status surfaces as the ApiException the caller already swallows.
  • RegisterDeviceTokenRequest in Api/Dtos/ mirroring the backend's contract by convention (no shared DTO project, per the existing decision).
  • IPushTokenProvider — the platform seam, alongside IConnectivityService and ITokenStore. Exposes Platform ("Android" / "iOS", exactly what RegisterDeviceTokenRequestValidator accepts) and GetTokenAsync().
  • LoginViewModel posts 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:

  • it runs after LoginSucceeded is raised, so navigation is never delayed by a slow or hanging network call;
  • a null or blank token — the permission-refused path — posts nothing;
  • every exception, from either the provider or the API call, is swallowed.

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

UnavailablePushTokenProvider always 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 supply google-services.json / GoogleService-Info.plist, and decide whether those files are committed given the detect-secrets hard 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-written FakeDevicesApi and FakePushTokenProvider matching the existing fake style:

  • a successful login registers the token with the correct platform string;
  • null / empty / whitespace tokens register nothing and the login still succeeds;
  • a throwing IDevicesApi does not fail the login;
  • a throwing IPushTokenProvider does 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:

  • A denied permission or a failed registration call never blocks or fails the login
  • A successful login registers the device's FCM token, and logging in again updates last_seen_at rather than duplicating the row
  • A due subscription for a user with a registered device results in a delivered push notification

Both unblock the moment #86 does.

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>
@rghvgrv
rghvgrv merged commit 2f683e5 into main Aug 6, 2026
2 checks passed
@rghvgrv
rghvgrv deleted the feat/issue-87-device-token-registration branch August 6, 2026 12:12
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>
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.

1 participant