Push Notifications
Firebase Cloud Messaging setup, token registration, and signed-in test pushes.
The Kotlin template uses Firebase Cloud Messaging for Android push, plus a
Supabase-backed device-token table and a signed-in Edge Function for smoke
tests. The app builds without Firebase configured; FCM activates when
app/google-services.json is present and a signed-in user registers a token.
What it provides
| Surface | Status | Implementation |
|---|---|---|
| FCM receive service | Integrated | FcmService extends FirebaseMessagingService |
| Runtime permission | Integrated | NotificationsScreen requests POST_NOTIFICATIONS on Android 13+ |
| Token registration | Integrated | NotificationsRepository upserts to public.device_tokens |
| In-app inbox | Example | Incoming messages are kept in an in-memory StateFlow, capped at 50 |
| Test sender | Integrated | send-test-push sends through FCM HTTP v1 using Supabase Edge secrets |
FCM needs Google Play services. It works on real Android devices and on emulator images that include Play services. You do not need a paid Play developer account for local FCM testing.
Key files
Setup
Create a Firebase project
Create or reuse a Firebase project for your Android app. Add the Android package
name from app/build.gradle.kts; the template default is:
com.soarstarter.kotlinAdd google-services.json
Download the Android client config from Firebase Console and place it at:
soar-kotlin/app/google-services.jsonapp/build.gradle.kts applies com.google.gms.google-services only when that
file exists, so CI and zero-key local builds still compile without Firebase.
The template README mentions app/google-services.json.example, but the
current template tree does not include that example file; use the JSON
downloaded from Firebase as the source of truth.
Apply the device-token migration
Apply supabase/migrations/20260508000001_create_device_tokens.sql. The table
stores user_id, token, platform = android, provider = fcm, and
timestamps. RLS lets users select, insert, update, and delete only their own
rows.
Deploy send-test-push
Deploy the signed-in test sender:
supabase functions deploy send-test-push --project-ref <ref>supabase/config.toml keeps verify_jwt = true for this function because the
Android app calls it as the current Supabase user.
Add Firebase Admin secrets
In Firebase Console, generate a service-account private key, then store it as a Supabase Edge secret:
supabase secrets set FIREBASE_SERVICE_ACCOUNT_JSON='<service-account-json>' --project-ref <ref>If multi-line JSON is awkward in your shell, use the split secrets instead:
FIREBASE_PROJECT_ID, FIREBASE_CLIENT_EMAIL, and FIREBASE_PRIVATE_KEY.
Token pipeline
Firebase token
-> FcmService.onNewToken() or NotificationsRepository.refreshAndRegisterToken()
-> NotificationsRepository.registerTokenOrThrow()
-> SupabaseDeviceTokenRemoteDataSource.register()
-> upsert public.device_tokens on (user_id, token)NotificationsScreen owns the explicit enable flow. On Android 13 and newer it
asks for Manifest.permission.POST_NOTIFICATIONS; on older versions it skips
the runtime permission and requests the Firebase token directly.
SupabaseDeviceTokenRemoteDataSource uses PostgREST upsert with:
onConflict = "user_id,token"
defaultToNull = falseThe migration has a matching unique constraint on (user_id, token), so
re-registering the same token refreshes updated_at and last_seen_at instead
of creating duplicates.
Current code registers tokens on explicit enable and when Firebase rotates a
token. It does not delete device_tokens on sign-out. Because the Edge
Function reads through the caller's RLS, a signed-out user cannot send a test
push, but old token rows can remain until you add explicit cleanup.
Receiving and opening pushes
FcmService.onMessageReceived() accepts either the FCM notification payload or
data keys:
{
"title": "Soar Starter test push",
"body": "Your Android FCM setup is working.",
"url": "soar://notifications"
}The service writes an AppNotification into the in-memory inbox and posts a
system notification on channel default. If the payload contains url, the
notification tap starts MainActivity with ACTION_VIEW and that URI. From
there, DeepLinkHandler forwards the URI through the normal deep-link resolver.
The inbox is intentionally simple sample state: it keeps the newest 50 notifications for the current process and clears when the process dies or the user taps Clear.
Test with the app
- Use a real device or a Play-services emulator image.
- Sign in to the app.
- Open Me -> Notifications.
- Grant
POST_NOTIFICATIONSwhen Android asks. - Tap Enable push notifications.
- Tap Send test push.
- Confirm the system notification appears and the in-app inbox gets a row.
NotificationsRepository.sendTestPush() refuses to send until a user is signed
in and a push token has been registered.
Test with curl
Use a signed-in Supabase access token from the same account that registered the device token:
curl -i \
-X POST https://<ref>.supabase.co/functions/v1/send-test-push \
-H "Authorization: Bearer <supabase-access-token>" \
-H "Content-Type: application/json" \
--data '{
"title": "Soar Starter test push",
"body": "Your Android FCM setup is working.",
"url": "soar://notifications"
}'The function never accepts a device token in the request body. It validates the
caller, reads that user's platform = android and provider = fcm rows through
RLS, and sends with server-held Firebase Admin credentials.
Customize
- Replace the in-memory inbox with a persisted table if notifications are a product feature.
- Add token cleanup on sign-out if you do not want old rows retained.
- Add topic, segment, or campaign metadata to
device_tokensonly after extending the RLS policies. - Keep Firebase Admin credentials in Supabase Edge secrets, never in the APK.
