Notifications and deep links
Push in both apps: FCM per flavor, device registration, the payload contract, foreground, background and terminated handling, routing by event, Android channels, App Links for invitation links, Play install referrer attribution and the iOS fallback.
Push notifications tell a closed app what changed; the in-app inbox keeps the history; the reseller app's SSE stream updates open screens (API integration › live updates). Which event notifies whom, through which channels and with which French copy is decided in the notifications matrix — this page is how the apps receive and route them (MOB-08, MRA-08, MRA-09, MDA-11; NOT-02, INT-02).
Firebase Cloud Messaging per flavor
Each flavor talks to its own Firebase project (hoco-dev, hoco-staging, hoco-prod), and each server environment sends with the service account of the matching project — tokens are project-scoped (repository › Firebase). Packages: firebase_core, firebase_messaging, flutter_local_notifications (channels and foreground banners).
Sending a test message to a dev build (token printed in the debug console and the diagnostics screen):
ACCESS_TOKEN=$(gcloud auth print-access-token)
curl -s -X POST "https://fcm.googleapis.com/v1/projects/hoco-dev/messages:send" \
-H "Authorization: Bearer $ACCESS_TOKEN" -H "Content-Type: application/json" \
-d '{"message":{"token":"<fcm-token>","notification":{"title":"Commande confirmée","body":"CMD-2026-000158"},
"android":{"notification":{"channel_id":"transactional"}},
"data":{"eventKey":"order.validated","route":"/orders/0192f4b8-7c1e-7a10-9f00-5b6d2c8e1a01",
"entityType":"ORDER","entityId":"0192f4b8-7c1e-7a10-9f00-5b6d2c8e1a01","notificationId":"0192f4c1-2b3a-7d44-8e21-3f9a0c7d5e02"}}}'Device registration
me.devices.register binds the install (deviceId = X-Device-Id) to the signed-in user with its FCM token, app id, version and locale. Guests have no session and register nothing.
| When | Why |
|---|---|
| Right after sign-in or sign-up | Bind the device to the new session |
| Every app start while signed in | Cheap and idempotent; refreshes appVersion for the version statistics |
FirebaseMessaging.instance.onTokenRefresh | Tokens rotate |
| After an app update | New appVersion |
| Notification permission changed | pushToken: null when denied keeps the device known without push |
Future<void> registerDevice(Ref ref) async {
final messaging = FirebaseMessaging.instance;
final settings = await messaging.getNotificationSettings();
final token = settings.authorizationStatus == AuthorizationStatus.authorized ? await messaging.getToken() : null;
final info = ref.read(clientInfoProvider);
await ref.read(hocoApiProvider).me.meDevicesRegister(
idempotencyKey: uuidV7(),
body: DeviceRegistration(
deviceId: info.deviceId,
platform: info.platform, // android
pushToken: token,
appId: info.appId, // RESELLER_APP or DELEGATE_APP
appVersion: info.appVersion, // 1.4.2
locale: info.locale,
osVersion: info.osVersion,
model: info.model,
),
);
}- Permission timing (Android 13+): the delegate app asks on the consent screen DA-04 (the tour notification is part of transparency). The reseller app asks after the first order is placed, or when the user opens the inbox, with one sentence of context (« Recevez le suivi de vos commandes en temps réel ») — not on first launch.
- Sign-out:
auth.logoutrevokes the session and clears the push token server-side. The « Notifications sur cet appareil » switch of RA-35 / DA-26 callsme.devices.remove(push off, session kept).
Payload contract
The server sends notification + data messages for everything the user should see (the system displays them even when the app process is dead, which matters on phones with aggressive battery killers), and data-only messages only as sync hints that always accompany a visible notification (matrix › payload):
{
"message": {
"token": "fcm-device-token",
"notification": { "title": "Commande confirmée", "body": "Votre commande CMD-2026-000158 de 125 000,00 DA est confirmée. Nous la préparons." },
"android": {
"collapse_key": "order:0192f4b8-7c1e-7a10-9f00-5b6d2c8e1a01",
"priority": "high",
"notification": { "channel_id": "transactional", "tag": "order:0192f4b8-7c1e-7a10-9f00-5b6d2c8e1a01" }
},
"data": {
"notificationId": "0192f4c1-2b3a-7d44-8e21-3f9a0c7d5e02",
"eventKey": "order.validated",
"route": "/orders/0192f4b8-7c1e-7a10-9f00-5b6d2c8e1a01",
"entityType": "ORDER",
"entityId": "0192f4b8-7c1e-7a10-9f00-5b6d2c8e1a01"
}
}
}| Field | App use |
|---|---|
data.route | Where to go — a path of the app's router (routes) |
data.eventKey | Analytics, special side effects (refresh Me on account.customer_type_changed, invalidate the tour on tour.updated) and the fallback mapping when route is unknown |
data.entityType, data.entityId | Invalidate the matching provider (order, return, ticket, tour) |
data.notificationId | Mark read with notifications.markRead when opened |
data.type = "sync", data.scope | Delegate app: run a delta pull (tours) |
android.notification.tag | One tray entry per record: a newer status replaces the older one |
All data values are strings (FCM requirement).
Handling by app state
| App state | Delivery | Handler | Behaviour |
|---|---|---|---|
| Foreground | Not displayed by the system | FirebaseMessaging.onMessage | No system notification. In-app banner (hoco_ui toast) unless the user is already on the target screen; invalidate the entity provider and the unread count. In the reseller app the SSE event usually arrives first — dedupe by notificationId |
| Background (process alive) | System tray | FirebaseMessaging.onMessageOpenedApp on tap | Route |
| Terminated | System tray | FirebaseMessaging.instance.getInitialMessage() at start | Keep the route as pending, run the start-up sequence (app config, session), then route |
| Data-only (sync hint) | Not displayed | FirebaseMessaging.onBackgroundMessage (top-level, @pragma('vm:entry-point')) | Schedule a one-off WorkManager sync with a network constraint; in foreground, pull directly |
@pragma('vm:entry-point')
Future<void> firebaseBackgroundHandler(RemoteMessage message) async {
if (message.data['type'] == 'sync') {
await scheduleOneOffSync(reason: 'push:${message.data['scope']}'); // WorkManager, network required
}
}
Future<void> wirePush(GoRouter router, PendingRoute pending) async {
FirebaseMessaging.onBackgroundMessage(firebaseBackgroundHandler);
FirebaseMessaging.onMessage.listen(showInAppBanner);
FirebaseMessaging.onMessageOpenedApp.listen((m) => openFromPush(router, m.data));
final initial = await FirebaseMessaging.instance.getInitialMessage();
if (initial != null) pending.set(initial.data); // consumed once the gate leaves Booting
}Routing
The app routes on data.route (matrix rule 1) after three checks:
- Known route — the path must match a route of this app version; otherwise open the inbox (RA-26, DA-23).
- Same account — the record is fetched with the current session; a
404because the notification belongs to another account shows « Cette notification concerne un autre compte ». - Gate first — a pending route waits for the start-up sequence; a guest (signed out since) goes to sign-in with
fromset to the route.
Route paths are shared with the reseller web and owned by the apps' routers; the server templates use them. Current mapping:
Reseller app
| Event keys | Route | Screen |
|---|---|---|
order.submitted, order.validated, order.refused, order.modified, order.status_changed, order.cancelled, delivery.failed | /orders/:orderId | RA-22 |
assisted_order.awaiting_confirmation, assisted_order.reminder | /orders/:orderId/confirm | RA-23 |
return.approved, return.rejected, return.received | /returns/:returnId | RA-40 |
ticket.replied, ticket.status_changed, ticket.resolved, ticket.rejected | /support/tickets/:ticketId | RA-34 |
account.approved, account.customer_type_changed | / | RA-09 |
account.rejected | /pending | RA-06 |
account.suspended | /signin (explicit message) | RA-07 |
payment.confirmed, payment.rejected | /finance/payments | RA-31 |
invoice.issued, invoice.overdue | /finance/invoices/:invoiceId | RA-30 |
promotion.started | /promotions/:promotionId | RA-25 |
daily_offer.published | /promotions | RA-24 |
announcement.published | /notifications | RA-26 |
security.password_changed, security.new_device | /account/settings | RA-35 |
Delegate app
| Event keys | Route | Screen |
|---|---|---|
tour.assigned, tour.updated | /tours/:tourId (plus a sync hint) | DA-06, DA-07 |
tour.reopened | /tours/:tourId/closing | DA-20 |
task.assigned, task.due | /tasks | DA-19 |
assisted_order.confirmed, assisted_order.rejected, assisted_order.expired | /orders/:orderId | DA-18 |
invitation.step_completed | /invitations/:invitationId | DA-16 |
announcement.published | /messages/:announcementId | DA-23 |
account.suspended | /signin (explicit message; outbox kept) | DA-02 |
security.password_changed | /account/settings | DA-26 |
Android notification channels
Channel ids equal the lowercase NotificationCategory the server puts in android.notification.channel_id; names come from the app's l10n (re-created on language change). The default channel (com.google.firebase.messaging.default_notification_channel_id in the manifest) is transactional; the small icon is a white-on-transparent glyph from Akram (ic_stat_hoco, DQA-01) set as default_notification_icon.
| Channel id | Name (fr) | Importance | Apps | Notes |
|---|---|---|---|---|
transactional | Commandes et compte | High | RA, DA | Cannot be disabled in preferences (category rule); the user can still mute it in Android |
security | Sécurité | High | RA, DA | |
field | Activité terrain | High | DA | Tours, tasks, assisted orders, invitation steps |
promotional | Offres et promotions | Default | RA | Marketing opt-out through preferences (NOT-06) |
announcement | Annonces | Default | RA, DA | |
tour_tracking | Tournée en cours | Low | DA | The ongoing foreground-service notification (location); never sent by the server |
sync | Synchronisation | Low | DA | Local notifications: 48-hour pending warning, sync errors |
Future<void> createChannels(FlutterLocalNotificationsPlugin plugin, AppLocalizations l10n) async {
final android = plugin.resolvePlatformSpecificImplementation<AndroidFlutterLocalNotificationsPlugin>();
for (final c in [
AndroidNotificationChannel('transactional', l10n.channelTransactional, importance: Importance.high),
AndroidNotificationChannel('security', l10n.channelSecurity, importance: Importance.high),
AndroidNotificationChannel('field', l10n.channelField, importance: Importance.high),
AndroidNotificationChannel('promotional', l10n.channelPromotional),
AndroidNotificationChannel('announcement', l10n.channelAnnouncement),
AndroidNotificationChannel('tour_tracking', l10n.channelTourTracking, importance: Importance.low),
AndroidNotificationChannel('sync', l10n.channelSync, importance: Importance.low),
]) {
await android?.createNotificationChannel(c);
}
}Each app creates only the channels it uses.
App Links for invitation links
A delegate shares https://app.hoco.dz/i/AB12CD34 (QR, WhatsApp, SMS — invitations). On Android with the reseller app installed, the link must open the app directly (IVT-04):
<activity android:name=".MainActivity" android:launchMode="singleTop" android:exported="true">
<intent-filter android:autoVerify="true">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="https" android:host="${appLinkHost}" android:pathPrefix="/i/" />
</intent-filter>
</activity>- Host per flavor through
manifestPlaceholders(app.hoco.dzforprod; Assumption —app-staging.hoco.dzandapp-dev.hoco.dzfor the others, with the final domains of D-12). - Verification file: the reseller web serves
https://app.hoco.dz/.well-known/assetlinks.jsonwith the package and the SHA-256 of the Play app-signing certificate (plus the upload key for internal builds). Imad sends the fingerprints; Anis deploys the file with the invitation landing page in WEB-01. The reseller web is part of V1 and deployed before the pilot, so links shared during the pilot work.
[
{
"relation": ["delegate_permission/common.handle_all_urls"],
"target": {
"namespace": "android_app",
"package_name": "dz.hoco.reseller",
"sha256_cert_fingerprints": ["14:6D:E9:83:C5:73:06:50:D8:EE:B9:95:2F:34:FC:64:16:A0:83:42:E6:1D:BE:A8:8A:04:96:B2:3F:CF:44:E5"]
}
}
]- Routing: Flutter's built-in deep linking hands
/i/AB12CD34to go_router; the/i/:coderoute stores the code (persisted), callsinvitations.attributewithcodeandsourceapp link on the first open, then redirects to/signup(or/when already signed in). - Testing before the domain serves the file:
adb shell pm set-app-links-user-selection --user cur --package dz.hoco.reseller.dev true app-dev.hoco.dzapproves the host manually; after deployment,adb shell pm verify-app-links --re-verify dz.hoco.resellerthenadb shell pm get-app-links dz.hoco.resellermust showverified.
Play install referrer
When the app is not installed, the landing page redirects to the Play Store with the referrer inv=<code> (…details?id=dz.hoco.reseller&referrer=inv%3DAB12CD34). On the first launch the app reads it once and reports the install to invitations.attribute, so the delegate is credited before sign-up (IVT-03).
Future<void> reportInstallOnce(Ref ref) async {
final prefs = ref.read(preferencesProvider);
if (await prefs.getBool('attribution.done') ?? false) return;
try {
final r = await PlayInstallReferrer.installReferrer; // package play_install_referrer
final raw = r.installReferrer ?? '';
final result = await ref.read(hocoApiProvider).invitations.invitationsAttribute(
idempotencyKey: uuidV7(),
body: AttributionRequest(
deviceId: ref.read(clientInfoProvider).deviceId,
installReferrer: raw, // "inv=AB12CD34&utm_medium=whatsapp"
referrerClickedAt: r.referrerClickTimestampSeconds.toUtcDateTimeOrNull(),
installBeganAt: r.installBeginTimestampSeconds.toUtcDateTimeOrNull(),
),
);
if (result.invitation != null) ref.read(invitationCodeProvider.notifier).set(result.invitation!.code);
} on Exception {
// no Play Store (sideloaded test build), referrer service unavailable, offline: retry on next launch
return;
}
await prefs.setBool('attribution.done', true);
}- Organic installs answer
attributed: false— nothing to show.alreadyRecorded: trueon a reinstall is normal. - A code typed on RA-03 and resolved is also sent to
invitations.attribute(source code), then withinvitationCodeat sign-up; the server applies the attribution precedence. - Test builds installed from Firebase App Distribution have no Play referrer; test attribution with an internal-track build and a real link.
iOS fallback
iOS is built after the pilot, only if HOCO decides it (D-10, MOB-16). What changes then:
- Push: an APNs authentication key uploaded to each Firebase project; the notification permission prompt on iOS is explicit and must come with context; the server adds
apns-collapse-id. - Universal Links:
apple-app-site-associationon the reseller web (/i/*) and the Associated Domains entitlement. - No install referrer: an iPhone user installing from the App Store loses the code. The landing page shows it large (« Code d'invitation : AB12-CD34 »), share texts include it, and RA-03 keeps the manual code field; attribution then happens at sign-up.
Location and maps
Consent and permissions, the location service, sampling only during tours through a foreground service, the GPS tolerance check at arrival, maps and external navigation in the delegate app, the regional WhatsApp lookup of the reseller app, and testing with mock locations.
Design system in Flutter
How hoco_ui implements Akram's design system: token JSON to generated Dart, ThemeData and ThemeExtensions, the Flutter mapping of the mobile component inventory, the Widgetbook gallery for design QA, RTL readiness, text scaling, accessibility and golden tests.