HOCO B2B
Mobile (Flutter)

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.

WhenWhy
Right after sign-in or sign-upBind the device to the new session
Every app start while signed inCheap and idempotent; refreshes appVersion for the version statistics
FirebaseMessaging.instance.onTokenRefreshTokens rotate
After an app updateNew appVersion
Notification permission changedpushToken: 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.logout revokes the session and clears the push token server-side. The « Notifications sur cet appareil » switch of RA-35 / DA-26 calls me.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"
    }
  }
}
FieldApp use
data.routeWhere to go — a path of the app's router (routes)
data.eventKeyAnalytics, 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.entityIdInvalidate the matching provider (order, return, ticket, tour)
data.notificationIdMark read with notifications.markRead when opened
data.type = "sync", data.scopeDelegate app: run a delta pull (tours)
android.notification.tagOne tray entry per record: a newer status replaces the older one

All data values are strings (FCM requirement).

Handling by app state

App stateDeliveryHandlerBehaviour
ForegroundNot displayed by the systemFirebaseMessaging.onMessageNo 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 trayFirebaseMessaging.onMessageOpenedApp on tapRoute
TerminatedSystem trayFirebaseMessaging.instance.getInitialMessage() at startKeep the route as pending, run the start-up sequence (app config, session), then route
Data-only (sync hint)Not displayedFirebaseMessaging.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:

  1. Known route — the path must match a route of this app version; otherwise open the inbox (RA-26, DA-23).
  2. Same account — the record is fetched with the current session; a 404 because the notification belongs to another account shows « Cette notification concerne un autre compte ».
  3. Gate first — a pending route waits for the start-up sequence; a guest (signed out since) goes to sign-in with from set 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 keysRouteScreen
order.submitted, order.validated, order.refused, order.modified, order.status_changed, order.cancelled, delivery.failed/orders/:orderIdRA-22
assisted_order.awaiting_confirmation, assisted_order.reminder/orders/:orderId/confirmRA-23
return.approved, return.rejected, return.received/returns/:returnIdRA-40
ticket.replied, ticket.status_changed, ticket.resolved, ticket.rejected/support/tickets/:ticketIdRA-34
account.approved, account.customer_type_changed/RA-09
account.rejected/pendingRA-06
account.suspended/signin (explicit message)RA-07
payment.confirmed, payment.rejected/finance/paymentsRA-31
invoice.issued, invoice.overdue/finance/invoices/:invoiceIdRA-30
promotion.started/promotions/:promotionIdRA-25
daily_offer.published/promotionsRA-24
announcement.published/notificationsRA-26
security.password_changed, security.new_device/account/settingsRA-35

Delegate app

Event keysRouteScreen
tour.assigned, tour.updated/tours/:tourId (plus a sync hint)DA-06, DA-07
tour.reopened/tours/:tourId/closingDA-20
task.assigned, task.due/tasksDA-19
assisted_order.confirmed, assisted_order.rejected, assisted_order.expired/orders/:orderIdDA-18
invitation.step_completed/invitations/:invitationIdDA-16
announcement.published/messages/:announcementIdDA-23
account.suspended/signin (explicit message; outbox kept)DA-02
security.password_changed/account/settingsDA-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 idName (fr)ImportanceAppsNotes
transactionalCommandes et compteHighRA, DACannot be disabled in preferences (category rule); the user can still mute it in Android
securitySécuritéHighRA, DA
fieldActivité terrainHighDATours, tasks, assisted orders, invitation steps
promotionalOffres et promotionsDefaultRAMarketing opt-out through preferences (NOT-06)
announcementAnnoncesDefaultRA, DA
tour_trackingTournée en coursLowDAThe ongoing foreground-service notification (location); never sent by the server
syncSynchronisationLowDALocal 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.

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.dz for prod; Assumption — app-staging.hoco.dz and app-dev.hoco.dz for the others, with the final domains of D-12).
  • Verification file: the reseller web serves https://app.hoco.dz/.well-known/assetlinks.json with 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/AB12CD34 to go_router; the /i/:code route stores the code (persisted), calls invitations.attribute with code and source app 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.dz approves the host manually; after deployment, adb shell pm verify-app-links --re-verify dz.hoco.reseller then adb shell pm get-app-links dz.hoco.reseller must show verified.

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: true on a reinstall is normal.
  • A code typed on RA-03 and resolved is also sent to invitations.attribute (source code), then with invitationCode at 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-association on 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.

On this page