From ff5005e2e997f37ca1c64c174a51cf94a9c0e4c0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?An=C4=B1lcan=20=C3=87ak=C4=B1r?= Date: Sun, 27 Sep 2026 02:11:26 +0300 Subject: [PATCH 1/2] feat: add PushStateReporter for push reachability reports --- CHANGELOG.md | 4 + assets/stubs/install/notification_config.stub | 12 + doc/basics/laravel-backend-setup.md | 39 + doc/basics/shipping-push.md | 29 + lib/magic_notifications.dart | 1 + lib/src/facades/notify.dart | 16 + lib/src/notification_manager.dart | 11 + lib/src/support/push_state_reporter.dart | 412 ++++++++++ test/support/push_state_reporter_test.dart | 729 ++++++++++++++++++ 9 files changed, 1253 insertions(+) create mode 100644 lib/src/support/push_state_reporter.dart create mode 100644 test/support/push_state_reporter_test.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index af1897e..c1d45fb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,10 @@ ## [Unreleased] ### Added +- **`PushStateReporter`, reached as `Notify.pushState`: the device tells its backend whether a push can reach it, and withdraws that on sign-out.** Moved here from the one app that had written it, so every adopter gets the same answer to "who keeps receiving pages after sign-out". `watch()` reports `PushDeliverySnapshot.toMap()` verbatim after every `onPushIdentityReconciled` pass towards the signed-in person, whenever the attached driver's permission or subscription stream moves (attached late through `onPushDriverAttached`), and once as `unavailable` off `Auth.stateNotifier` for a build with no driver at all, since no pass is emitted there. A memo of the last ACCEPTED state stops repeats, a refused post is retried on the next event, and the memo is forgotten on every `AuthLogout`. `release()` posts `{subscription_id}` alone for this device (the live read, falling back to the last accepted report's id), must run before `Auth.logout()` drops the token, joins a release already in flight and does not post a subscription it already released, so calling it twice is safe. `isConfigured` lets a starter kit's sign-out path skip it. `forget()` clears the memo. + + **Off by default, with no default path.** `notifications.push_state.report_path` and `release_path` name the endpoints and are null in the install stub, so an app whose backend has no such route sends nothing. `notifications.push_state.external_id_prefix` (default `user_`) is how the reporter recognises a pass for the signed-in person, ``; it has to match what the app passes to `Notify.initializePush`. `NotificationManager.forgetDrivers()` resets the reporter too, because its watch lives on the manager's streams. The backend contract is documented in `doc/basics/laravel-backend-setup.md`. (`lib/src/support/push_state_reporter.dart`, `lib/src/notification_manager.dart`, `lib/src/facades/notify.dart`, `lib/magic_notifications.dart`, `assets/stubs/install/notification_config.stub`, `test/support/push_state_reporter_test.dart`, `doc/basics/shipping-push.md`, `doc/basics/laravel-backend-setup.md`) + - **A push permission soft prompt component**, at `lib/src/ui/components/push_prompt/` following the `notification_dropdown/` folder shape (component, recipe, preview, index). `PushPrompt` renders the four presentations `pushPromptAdvice` can produce (`unavailable`, `blocked` with or without a settings route back, `off` asking or already declined, `on`) from `reachability`/`action`/`declined`/`busy` and reports two callbacks; it touches no platform API itself. `PushPromptHost` wires it to `Notify.manager.pushPromptAdvice()`, re-reading on the attached driver's `onPermissionChanged` and `onIdentityChanged` streams so a grant landing OUT OF BAND still clears the row, and on `NotificationManager.onPushDriverAttached` so a driver that resolves after the widget mounts is picked up too rather than leaving the row on `unavailable` until it remounts. That is the common path, since `requestPermission()` on an already-denied device opens the platform settings page rather than raising a dialog. `PushOffNotice` is a quiet, tappable shell marker for wherever a host's own chrome wants one, following the same two device streams and the same late-driver signal. **Two decisions carried over from where this pattern was proven, both load-bearing.** `PushPromptHost` takes the vault key for its decline timestamp as a REQUIRED constructor parameter rather than owning a fixed one: this package already refuses to own the decline itself (`pushPromptAdvice(declinedAt:)` takes it as an argument, for the same reason: the decline is the host's own UI event), and a fixed key here would either collide with whatever a host already stores or force every host onto one name. The widget only ever READS an ISO-8601 instant back; a value some older build wrote in a different shape is the host's own migration to make, once, before ever constructing this widget. `PushOffNotice` takes a required `onOpenPreferences` callback instead of navigating to a fixed route, so this package gains no dependency on any starter kit's routing convention. diff --git a/assets/stubs/install/notification_config.stub b/assets/stubs/install/notification_config.stub index 9b96649..52df820 100644 --- a/assets/stubs/install/notification_config.stub +++ b/assets/stubs/install/notification_config.stub @@ -107,6 +107,18 @@ Map get notificationConfig => { // API, from the system that already owns the fact. 'share_user_attributes': false, }, + // Optional. Where `Notify.pushState` tells your backend whether a push can + // reach this device, and where a sign-out releases it. Both are relative + // to the HTTP base url, and both are null (off) until your backend has + // the endpoints: there is no default path, so an app without them sends + // nothing. See doc/basics/laravel-backend-setup.md for the contract. + 'push_state': { + 'report_path': null, // e.g. '/devices/push-state' + 'release_path': null, // e.g. '/devices/push-state/release' + // Optional. The prefix your declared external id carries, 'user_' when + // absent. Must match what you pass to Notify.initializePush. + 'external_id_prefix': null, + }, 'database': { 'enabled': true, 'polling_interval': 30, // seconds diff --git a/doc/basics/laravel-backend-setup.md b/doc/basics/laravel-backend-setup.md index 0549778..5e730a3 100644 --- a/doc/basics/laravel-backend-setup.md +++ b/doc/basics/laravel-backend-setup.md @@ -490,6 +490,45 @@ public function routeNotificationForOneSignal(): array > [!TIP] > The `user_` prefix is required to avoid OneSignal's blocked external_id values. Both Flutter and Laravel must use the same format: `user_{id}`. +### Device Reachability (Optional) + +OneSignal accepts a push for a subscription that cannot be woken, so only the +device knows whether a page will arrive. `Notify.pushState` reports that to +your backend and withdraws it on sign-out. It stays off until the client names +both endpoints (there is no default path): + +```dart +'push_state': { + 'report_path': '/devices/push-state', + 'release_path': '/devices/push-state/release', +}, +``` + +Both routes sit behind `auth:sanctum`. The user is always the session's; no +body field names a person beyond the device's own alias. + +**`POST {report_path}`** carries `PushDeliverySnapshot.toMap()` unchanged: + +| Key | Rule | +|---|---| +| `external_id` | present, nullable string; must equal the caller's own alias (`user_{id}`) when set | +| `subscription_id` | present, nullable string | +| `reachability` | one of `on`, `off`, `blocked`, `unavailable` (the `PushReachability` names) | +| `captured_at` | ISO-8601 UTC, the device's own clock | + +Store it per `(user, subscription_id)` with `updateOrCreate`, stamp your own +`reported_at`, and answer `204`. A null key is a fact ("this device holds no +subscription id"), so validate with `present`, not `sometimes`. + +**`POST {release_path}`** carries `{"subscription_id": "..."}` only. Delete that +one row for the session's user and answer `204`. The client calls it before +`Auth.logout()` drops the token, and only for the device being signed out of: +the person's other devices keep paging them. + +The client reports on change (sign-in, permission, subscription swap), never on +a timer, and re-posts only after a refusal. A refused release is logged and the +sign-out proceeds, so size your freshness horizon for a stale row. + --- ## Socket Delivery (Broadcast) diff --git a/doc/basics/shipping-push.md b/doc/basics/shipping-push.md index e828698..a6af74e 100644 --- a/doc/basics/shipping-push.md +++ b/doc/basics/shipping-push.md @@ -135,10 +135,39 @@ updating, check the site type before the config. --- +## Telling your backend whether the device can be paged + +Everything above decides whether a push CAN arrive. Whether your backend knows +it is a separate question: OneSignal accepts a push for a subscription that is +denied, opted out or gone, and reports nothing back. If your backend escalates +on push alone, `Notify.pushState` gives it the device's own answer. Configure +both endpoints (see [Laravel Backend Setup](laravel-backend-setup.md#onesignal-push) +for the server half), arm the watch once, and release before the token goes: + +```dart +// In a provider's boot(), once auth is registered. +Notify.pushState.watch(); + +// On sign-out, BEFORE Auth.logout(): a release after it is a guaranteed 401. +if (Notify.pushState.isConfigured) await Notify.pushState.release(); +await Auth.logout(); +``` + +`watch()` reports after every identity reconcile for the signed-in person +(``, `user_` by default), whenever the driver's +permission or subscription moves, and once as `unavailable` for a build with no +push driver at all. A memo stops repeats and is forgotten on every +`AuthLogout`. Skip the release and the server keeps vouching for a handset the +person has left, under their name, until its own freshness horizon expires. + +--- + ## Before you submit - `dart run :artisan notifications:doctor` is clean, including the iOS configuration rows. +- If your backend reads device reachability, a sign-out from the release build + posts to `notifications.push_state.release_path` before the token is gone. - The `.env` inside the built artifact is the production one. Unzip the `.ipa` and read `Payload/*.app/Frameworks/App.framework/flutter_assets/.env`. - The signed binary carries the production entitlement: diff --git a/lib/magic_notifications.dart b/lib/magic_notifications.dart index 3263a44..b9f9f53 100644 --- a/lib/magic_notifications.dart +++ b/lib/magic_notifications.dart @@ -17,6 +17,7 @@ export 'src/models/push_user_attributes.dart'; // Core export 'src/notification_manager.dart'; export 'src/notification_poller.dart'; +export 'src/support/push_state_reporter.dart'; // Facade export 'src/facades/notify.dart'; diff --git a/lib/src/facades/notify.dart b/lib/src/facades/notify.dart index cf3fb41..ce18422 100644 --- a/lib/src/facades/notify.dart +++ b/lib/src/facades/notify.dart @@ -6,6 +6,7 @@ import '../models/database_notification.dart'; import '../models/paginated_notifications.dart'; import '../models/push_user_attributes.dart'; import '../notification_manager.dart'; +import '../support/push_state_reporter.dart'; import '../ui/notification_view_registry.dart'; import '../ui/views/notification_preferences_view.dart'; import '../ui/views/notifications_list_view.dart'; @@ -239,6 +240,21 @@ class Notify { await manager.logoutPush(); } + /// Reports whether a push can reach this device to the app's backend, and + /// releases the device on sign-out. + /// + /// Off until `notifications.push_state.report_path` and `release_path` are + /// configured. Arm it once from a provider's `boot()`, and release before + /// the token is dropped: + /// + /// ```dart + /// Notify.pushState.watch(); + /// + /// if (Notify.pushState.isConfigured) await Notify.pushState.release(); + /// await Auth.logout(); + /// ``` + static PushStateReporter get pushState => manager.pushState; + /// Registers how this app describes whoever signs in, once, for every later /// login and account switch. /// diff --git a/lib/src/notification_manager.dart b/lib/src/notification_manager.dart index 453d221..4d9cab3 100644 --- a/lib/src/notification_manager.dart +++ b/lib/src/notification_manager.dart @@ -16,6 +16,7 @@ import 'models/push_subscription.dart'; import 'models/push_user_attributes.dart'; import 'notification_poller.dart'; import 'support/notification_log.dart'; +import 'support/push_state_reporter.dart'; /// Core notification manager. /// @@ -218,6 +219,11 @@ class NotificationManager { final StreamController _sessionClearedController = StreamController.broadcast(); + /// Reports this device's push reachability to the host's backend, and + /// releases it on sign-out. Off until the app configures its endpoints; see + /// [PushStateReporter]. + late final PushStateReporter pushState = PushStateReporter(this); + /// Notification poller for periodic fetching NotificationPoller? _poller; @@ -366,7 +372,12 @@ class NotificationManager { /// test would otherwise describe the person in the next. What it wrote is /// forgotten rather than taken back, because this seam does not touch a /// device; a driver it just dropped is not one to issue removals through. + /// + /// [pushState] is reset with them: its watch lives on this manager's + /// streams, and a subscription surviving here would report inside the next + /// test. void forgetDrivers() { + pushState.reset(); _channels.clear(); _pushFactories.clear(); _pushDriver = null; diff --git a/lib/src/support/push_state_reporter.dart b/lib/src/support/push_state_reporter.dart new file mode 100644 index 0000000..e461ad3 --- /dev/null +++ b/lib/src/support/push_state_reporter.dart @@ -0,0 +1,412 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart' show ValueNotifier; +import 'package:magic/magic.dart'; + +import '../drivers/push/push_driver.dart'; +import '../models/push_delivery_snapshot.dart'; +import '../models/push_identity_reconciled.dart'; +import '../models/push_subscription.dart'; +import '../notification_manager.dart'; +import 'notification_log.dart'; + +/// Tells the host's backend whether a push sent to this device would arrive, +/// and stops this device vouching for a person who signs out of it. +/// +/// The server cannot see any of this. The permission, the opt-in flag and the +/// subscription id all live on the device, and OneSignal accepts a push for an +/// unreachable subscription without complaint, so this report is the only +/// evidence a backend deciding whom to page will ever have that the device can +/// actually be woken. +/// +/// Reached as `Notify.pushState`. Off until the app names both endpoints: +/// +/// ```dart +/// 'push_state': { +/// 'report_path': '/devices/push-state', +/// 'release_path': '/devices/push-state/release', +/// }, +/// ``` +/// +/// There is no default path, so an app whose backend has no such endpoint +/// sends nothing. The report body is [PushDeliverySnapshot.toMap] verbatim and +/// the release body is the subscription id alone: the person always comes from +/// the session on the server side, never from a body field. +/// +/// ## Reported on change, never on a timer +/// +/// This fact changes a handful of times in a device's life: a permission +/// granted or revoked, a subscription minted or swapped, a person signing in. +/// Every one of those is an event the platform already reports, so [watch] +/// follows those events and a memo stops the repeats. A starter kit +/// re-declares the push identity on every auth bump (restore, token refresh, +/// team switch), and none of those move the device. +class PushStateReporter { + /// Creates a reporter reading the device through [manager]. + PushStateReporter(this._manager); + + /// Config key for the report endpoint, relative to the HTTP base url. + static const String reportPathKey = 'notifications.push_state.report_path'; + + /// Config key for the release endpoint, relative to the HTTP base url. + static const String releasePathKey = 'notifications.push_state.release_path'; + + /// Config key for the prefix the signed-in person's external id carries. + /// + /// Has to match whatever the app declares through `Notify.initializePush` + /// and whatever the backend addresses pushes to, because a pass is only + /// reported when its intent is ``. + static const String externalIdPrefixKey = + 'notifications.push_state.external_id_prefix'; + + /// The prefix this package documents for `Notify.initializePush`. + static const String _defaultExternalIdPrefix = 'user_'; + + /// The manager whose driver is read and whose streams are followed. + final NotificationManager _manager; + + /// The last state the server accepted, or `null` when nothing has been. + /// + /// The device's own facts only: reachability, external id, subscription id. + /// `captured_at` is deliberately left out, since it moves on every read and + /// would turn the memo into a permanent miss. + /// + /// It goes with the session: [forget] runs on every `AuthLogout`. Two people + /// share one handset, and the second one's state can be identical to the + /// first's; a memo surviving the sign-out would read it as already reported + /// and the server would have no row for the person now holding the phone. + String? _lastReportedState; + + /// The subscription id the last ACCEPTED report was written under. + /// + /// Held beside the memo rather than parsed out of it because it answers a + /// different question: which server row a release has to remove. [release] + /// falls back to it when the live read produces no subscription id. [forget] + /// leaves it alone, since it names this device's own row and is only ever + /// read by a release made under a live session. + String? _reportedSubscriptionId; + + /// The subscription id this session already released, so a second + /// [release] before anything new was reported posts nothing. + String? _releasedSubscriptionId; + + /// The release currently in the air, joined by a caller arriving mid-flight. + Future? _releaseInFlight; + + /// The driver whose change streams are watched, compared by identity so a + /// replaced driver is picked up rather than left unheard. + PushDriver? _watchedDriver; + + StreamSubscription? _identityChanges; + StreamSubscription? _permissionChanges; + StreamSubscription? _reconciledPasses; + StreamSubscription? _driverArrivals; + + /// The auth notifier the driver-less listener was added to, held so it is + /// removed from that same notifier even after the guard was rebound. + ValueNotifier? _authState; + + /// Stops the `AuthLogout` listener [watch] installs. + void Function()? _stopForgettingOnLogout; + + /// Whether this app names a report endpoint at all. + /// + /// A starter kit asks this before calling [release] from its sign-out path, + /// so an app without the backend half pays nothing on the way out. + bool get isConfigured => _reportPath != null; + + /// Starts keeping the backend's picture of this device current. + /// + /// Follows three signals, and each covers a case the others cannot: + /// + /// 1. `NotificationManager.onPushIdentityReconciled`. The report describes + /// the device AS THE SIGNED-IN PERSON, and the pass is what makes that + /// true, so the report rides the pass rather than the declaration. A pass + /// that threw or read back a mismatch still reports, so the server hears + /// the device's real state. Only a pass towards `` + /// reports: a sign-out pass carries no intent, and a pass for another + /// subject would be refused by a server that checks the alias against + /// the session. + /// 2. The attached driver's permission and identity streams, attached from + /// `NotificationManager.onPushDriverAttached` because the driver is + /// normally resolved after the auth provider restored a session. + /// 3. `Auth.stateNotifier`, for a build with no driver at all. No pass is + /// emitted for a driver-less reconcile, so without this such a device + /// would report nothing for the whole session. It posts `unavailable` + /// rather than withholding: withholding leaves whatever the server held, + /// and on a device that lost its driver that is a stale `on` promising a + /// page nobody receives. + /// + /// Also forgets the memo on every `AuthLogout`. Idempotent: a second call + /// replaces the first watch rather than doubling it. Call it once auth is + /// registered, from a provider's `boot()`. + void watch() { + _stopWatching(); + if (!isConfigured) return; + + _reconciledPasses = _manager.onPushIdentityReconciled.listen( + _reportIfReconciledForCurrentUser, + ); + _driverArrivals = _manager.onPushDriverAttached.listen( + (PushDriver _) => _watchDriverChanges(), + ); + _stopForgettingOnLogout = Event.listenAny(_forgetOnLogout); + + final ValueNotifier authState = Auth.stateNotifier; + authState.addListener(_reportWhenDriverless); + _authState = authState; + + // The attach signal does not replay, so a driver already resolved when + // this is armed is read here. It runs before the driver-less check so an + // existing driver is seen before that check decides there is none. + _watchDriverChanges(); + _reportWhenDriverless(); + } + + /// Tells the backend to stop counting this device as reaching the person + /// signing out of it. + /// + /// Has to run BEFORE `Auth.logout()`: that call drops the bearer token, and + /// a release posted after it is a guaranteed 401. Awaiting it costs the + /// person one request on the way out. + /// + /// Names one device, by its subscription id, and not every row the person + /// owns: an operator signing out of a browser tab still carries the phone + /// that pages them. The live read is preferred and the last accepted + /// report's subscription id is the fallback, so a driver that stopped + /// answering still releases the row it created. With neither there is + /// nothing to release. + /// + /// Safe to call twice: a caller arriving mid-flight joins the release in + /// the air, and a subscription this session already released is not posted + /// again until a new report is accepted for it. + /// + /// A refused or failed release is logged and the sign-out proceeds; the + /// memo is left as it was, because the server still holds the old row. + Future release() { + final Future? inFlight = _releaseInFlight; + if (inFlight != null) return inFlight; + + final Future attempt = _release(); + _releaseInFlight = attempt; + + return attempt.whenComplete(() { + if (identical(_releaseInFlight, attempt)) _releaseInFlight = null; + }); + } + + /// Forgets what the server was last told for this session. + /// + /// Runs on every `AuthLogout` once [watch] is armed. The reported + /// subscription id stays: it names this device's own row. + void forget() { + _lastReportedState = null; + _releasedSubscriptionId = null; + } + + /// Leaves the reporter as a fresh process would find it: nothing watched, + /// nothing remembered. + /// + /// The test-isolation seam, run from `NotificationManager.forgetDrivers`. + /// The manager is a `static final` singleton that outlives a container + /// reset, so a subscription left on its streams would turn the next test's + /// reconcile into a report nothing there asked for. + void reset() { + _stopWatching(); + _stopWatchingDriver(); + + _watchedDriver = null; + _lastReportedState = null; + _reportedSubscriptionId = null; + _releasedSubscriptionId = null; + _releaseInFlight = null; + } + + /// The configured report endpoint, or `null` when the app names none. + String? get _reportPath => _configuredPath(reportPathKey); + + /// The configured release endpoint, or `null` when the app names none. + String? get _releasePath => _configuredPath(releasePathKey); + + /// Reads [key], answering `null` for an absent or blank value. + String? _configuredPath(String key) { + final String? path = Config.get(key)?.trim(); + if (path == null || path.isEmpty) return null; + + return path; + } + + /// The external id the signed-in person is subscribed as, or `null` for a + /// session whose user has not resolved yet. + String? _currentExternalId() { + final String userId = '${Auth.id() ?? ''}'; + if (userId.isEmpty) return null; + + final String? configured = Config.get(externalIdPrefixKey)?.trim(); + final String prefix = configured == null || configured.isEmpty + ? _defaultExternalIdPrefix + : configured; + + return '$prefix$userId'; + } + + /// Posts the device's state when it differs from what the server accepted. + /// + /// The memo advances only on an accepted report. A refused post left + /// nothing behind, so remembering it would silence this device until its + /// state changed again, which for a device that is off is never. A refusal + /// is logged rather than surfaced: nothing the person can do about it. + Future _report() async { + final String? path = _reportPath; + if (path == null) return; + + // The endpoint sits behind the session, so a signed-out report is a + // guaranteed 401. + if (!Auth.check()) return; + + try { + final PushDeliverySnapshot snapshot = + await _manager.pushDeliverySnapshot(); + final String state = '${snapshot.reachability.name}' + '|${snapshot.externalId ?? ''}' + '|${snapshot.subscriptionId ?? ''}'; + + if (state == _lastReportedState) return; + + final MagicResponse response = await Http.post( + path, + data: snapshot.toMap(), + ); + + if (!response.successful) { + NotificationLog.error( + 'Push delivery report refused with ${response.statusCode}', + ); + + return; + } + + _lastReportedState = state; + _reportedSubscriptionId = snapshot.subscriptionId; + _releasedSubscriptionId = null; + } catch (error) { + // Logged and left: the memo is untouched, so the next lifecycle event + // reports again rather than this device going quiet. + NotificationLog.error('Push delivery report failed: $error'); + } + } + + /// One release attempt, with no single-flight of its own. + Future _release() async { + // 1. Nowhere to send it, nobody to release, or no token to do it with. + final String? path = _releasePath; + if (path == null) return; + if (!Auth.check()) return; + + try { + // 2. Which device this is. The manager's read answers `unavailable` with + // no ids for a driver that throws rather than failing the sign-out. + final PushDeliverySnapshot snapshot = + await _manager.pushDeliverySnapshot(); + final String? subscriptionId = + snapshot.subscriptionId ?? _reportedSubscriptionId; + if (subscriptionId == null) return; + if (subscriptionId == _releasedSubscriptionId) return; + + final MagicResponse response = await Http.post( + path, + data: { + 'subscription_id': subscriptionId, + }, + ); + + if (!response.successful) { + NotificationLog.error( + 'Push device release refused with ${response.statusCode}', + ); + + return; + } + + // 3. The server holds no row for this device any more, so neither half + // of the memo may claim it does: the next person reports from scratch. + _lastReportedState = null; + _reportedSubscriptionId = null; + _releasedSubscriptionId = subscriptionId; + } catch (error) { + NotificationLog.error('Push device release failed: $error'); + } + } + + /// Reports after [pass] when it reconciled towards the signed-in person. + void _reportIfReconciledForCurrentUser(PushIdentityReconciled pass) { + if (!Auth.check()) return; + + final String? expected = _currentExternalId(); + if (expected == null || pass.intent != expected) return; + + unawaited(_report()); + } + + /// Reports this device's reachability when it has no push driver at all. + void _reportWhenDriverless() { + if (!Auth.check()) return; + if (_manager.pushDriverOrNull != null) return; + + unawaited(_report()); + } + + /// Forgets the memo when a session ends, on every sign-out path. + void _forgetOnLogout(MagicEvent event) { + if (event is AuthLogout) forget(); + } + + /// Watches the current driver's change streams, if it is not already + /// watching that driver. + /// + /// Both subscriptions carry an `onError` because both streams carry errors + /// (a failed platform-channel read is piped into the controller), and an + /// unhandled one would reach the zone as an app error. + void _watchDriverChanges() { + final PushDriver? driver = _manager.pushDriverOrNull; + if (driver == null || identical(driver, _watchedDriver)) return; + + _stopWatchingDriver(); + _watchedDriver = driver; + + _identityChanges = driver.onIdentityChanged.listen( + (PushIdentityChange _) => unawaited(_report()), + onError: (Object error) => NotificationLog.error( + 'Push identity stream failed: $error', + ), + ); + _permissionChanges = driver.onPermissionChanged.listen( + (PushPermissionState _) => unawaited(_report()), + onError: (Object error) => NotificationLog.error( + 'Push permission stream failed: $error', + ), + ); + } + + /// Drops the pass, driver-arrival, auth and logout listeners [watch] holds. + void _stopWatching() { + unawaited(_reconciledPasses?.cancel()); + unawaited(_driverArrivals?.cancel()); + _reconciledPasses = null; + _driverArrivals = null; + + _authState?.removeListener(_reportWhenDriverless); + _authState = null; + + _stopForgettingOnLogout?.call(); + _stopForgettingOnLogout = null; + } + + /// Drops both driver change subscriptions, keeping the watched driver. + void _stopWatchingDriver() { + unawaited(_identityChanges?.cancel()); + unawaited(_permissionChanges?.cancel()); + _identityChanges = null; + _permissionChanges = null; + } +} diff --git a/test/support/push_state_reporter_test.dart b/test/support/push_state_reporter_test.dart new file mode 100644 index 0000000..3e61f7f --- /dev/null +++ b/test/support/push_state_reporter_test.dart @@ -0,0 +1,729 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:magic/magic.dart'; +import 'package:magic_notifications/magic_notifications.dart'; + +import '../test_helper.dart'; + +/// A push driver whose permission, subscription and identity a test moves by +/// hand, announcing each move the way the platform SDK does. +class _ReportingPushDriver extends PushDriver { + /// The two change streams the reporter watches. Broadcast because the + /// manager attaches to them at registration and the reporter attaches + /// beside it. + final StreamController _permissionChanges = + StreamController.broadcast(); + final StreamController _identityChanges = + StreamController.broadcast(); + + /// When true, every `login` throws the way a failed SDK call does, so the + /// device keeps carrying nobody. + bool failLogin = false; + + /// The platform permission this device currently holds. + PushPermissionState permission = PushPermissionState.authorized; + + /// The subscription id the platform holds, or null for a device with no + /// address at all. + String? subscriptionId = 'sub-phone'; + + /// The external id this fake device carries, read back the way a real SDK + /// does so the manager's reconcile is not fooled. + String? _externalId; + + /// Reports a permission change the way the OS does with the app open. + void changePermission(PushPermissionState next) { + permission = next; + _permissionChanges.add(next); + } + + /// Reports the SDK swapping this device's push subscription. + void changeSubscription(String? next) { + subscriptionId = next; + _identityChanges.add(PushIdentityChange(subscriptionId: next)); + } + + /// Closes both controllers, so a stream does not outlive its test. + Future dispose() async { + await _permissionChanges.close(); + await _identityChanges.close(); + } + + @override + String get name => 'onesignal'; + + @override + bool get isSupported => true; + + @override + bool get isOptedIn => true; + + @override + Future permissionState() async => permission; + + @override + Future initialize(Map config) async {} + + @override + Future login(String externalId) async { + if (failLogin) throw StateError('the SDK refused the login'); + + _externalId = externalId; + } + + @override + Future logout() async { + _externalId = null; + } + + @override + Future currentExternalId() async => _externalId; + + @override + Future currentSubscriptionId() async => subscriptionId; + + @override + Future requestPermission() async => true; + + @override + Future optIn() async {} + + @override + Future optOut() async {} + + @override + Future setTags(Map tags) async {} + + @override + Future removeTag(String key) async {} + + @override + Stream get onNotificationReceived => + const Stream.empty(); + + @override + Stream get onNotificationClicked => + const Stream.empty(); + + @override + Stream get onPermissionChanged => + _permissionChanges.stream; + + @override + Stream get onIdentityChanged => _identityChanges.stream; +} + +/// A user model the fake auth guard can hold. +class _User extends Model with Authenticatable { + @override + String get table => 'users'; + + @override + String get resource => 'users'; + + @override + List get fillable => ['id']; +} + +void main() { + /// The endpoint a report is posted to, as the app configures it. + const String reportPath = '/devices/push-state'; + + /// The endpoint a sign-out releases this device through. + const String releasePath = '/devices/push-state/release'; + + setUpAll(() async { + await initMagicForTests(); + }); + + setUp(() { + Notify.forgetDrivers(); + Config.set('notifications.push_state.report_path', reportPath); + Config.set('notifications.push_state.release_path', releasePath); + Config.set('notifications.push_state.external_id_prefix', null); + }); + + tearDown(() { + Notify.forgetDrivers(); + Auth.unfake(); + }); + + /// Installs [driver] as the push rail. + _ReportingPushDriver useDriver([_ReportingPushDriver? driver]) { + final _ReportingPushDriver installed = driver ?? _ReportingPushDriver(); + Notify.manager.setPushDriver(installed); + addTearDown(installed.dispose); + + return installed; + } + + /// A persisted user carrying [id]. + _User makeUser(String id) { + return _User() + ..fill({'id': id}) + ..exists = true; + } + + /// Signs [id] in on a fresh fake guard. + void signIn(String id) { + Auth.fake(user: makeUser(id)); + } + + /// Every report this device has posted, in order. Matched on the END of the + /// url because the release path extends this one. + List> reports(FakeNetworkDriver network) { + return network.recorded + .where((entry) => entry.$1.url.endsWith(reportPath)) + .map((entry) => entry.$1.data as Map) + .toList(); + } + + /// Every release this device has posted, in order. + List> releases(FakeNetworkDriver network) { + return network.recorded + .where((entry) => entry.$1.url.endsWith(releasePath)) + .map((entry) => entry.$1.data as Map) + .toList(); + } + + /// Declares [externalId] again, which is what a starter kit does on every + /// auth bump, and lets the reconcile pass behind it settle. + Future redeclare(String externalId) async { + await Notify.initializePush(externalId); + await pumpEventQueue(); + } + + /// Signs `u1` in, arms the watch and lets the declaration settle. + Future declareIdentity() async { + signIn('u1'); + Notify.pushState.watch(); + await redeclare('user_u1'); + } + + group('configuration', () { + test('both paths set reads as configured', () { + expect(Notify.pushState.isConfigured, isTrue); + }); + + test('an absent report path reads as not configured', () { + Config.set('notifications.push_state.report_path', null); + + expect(Notify.pushState.isConfigured, isFalse); + }); + + test('a blank report path reads as not configured', () { + Config.set('notifications.push_state.report_path', ' '); + + expect(Notify.pushState.isConfigured, isFalse); + }); + + test('an absent report path sends nothing at all', () async { + // No default path: an app whose backend has no such endpoint must not + // be posting a 404 on every sign-in. + Config.set('notifications.push_state.report_path', null); + Config.set('notifications.push_state.release_path', null); + final FakeNetworkDriver network = Http.fake(); + useDriver(); + + await declareIdentity(); + await Notify.pushState.release(); + + expect( + network.recorded.where((entry) => entry.$1.url.contains('push-state')), + isEmpty, + ); + }); + + test('an absent release path releases nothing', () async { + Config.set('notifications.push_state.release_path', null); + final FakeNetworkDriver network = Http.fake(); + useDriver(); + await declareIdentity(); + + await Notify.pushState.release(); + + expect(reports(network), hasLength(1)); + expect( + network.recorded.where((entry) => entry.$1.url.endsWith('release')), + isEmpty, + ); + }); + + test('the report is posted to the configured path, verbatim', () async { + Config.set('notifications.push_state.report_path', '/me/device'); + final FakeNetworkDriver network = Http.fake(); + useDriver(); + + await declareIdentity(); + + expect(network.recorded.single.$1.url, '/me/device'); + }); + + test('a configured prefix decides which pass is the signed-in person', + () async { + Config.set('notifications.push_state.external_id_prefix', 'member_'); + final FakeNetworkDriver network = Http.fake(); + useDriver(); + signIn('u1'); + Notify.pushState.watch(); + + await redeclare('user_u1'); + expect(reports(network), isEmpty); + + await redeclare('member_u1'); + expect(reports(network).single['external_id'], 'member_u1'); + }); + }); + + group('the report follows a reconcile pass for the signed-in person', () { + test('a pass reconciled towards user_ posts one report', + () async { + final FakeNetworkDriver network = Http.fake(); + useDriver(); + signIn('u1'); + Notify.pushState.watch(); + + await redeclare('user_u1'); + + expect(reports(network), hasLength(1)); + expect(reports(network).single['external_id'], 'user_u1'); + }); + + test('a pass reconciled towards somebody else posts nothing', () async { + // The server refuses a report under an alias that is not the session's, + // so posting would buy a 422 and nothing else. + final FakeNetworkDriver network = Http.fake(); + useDriver(); + signIn('u1'); + Notify.pushState.watch(); + + await redeclare('user_u2'); + + expect(reports(network), isEmpty); + }); + + test('a pass that declares nobody posts nothing', () async { + final FakeNetworkDriver network = Http.fake(); + useDriver(); + signIn('u1'); + Notify.pushState.watch(); + + await Notify.logoutPush(); + await pumpEventQueue(); + + expect(reports(network), isEmpty); + }); + + test('a failed pass still reports', () async { + // A converged-only trigger would leave the server vouching for + // whatever it held before. + final FakeNetworkDriver network = Http.fake(); + useDriver(_ReportingPushDriver()..failLogin = true); + signIn('u1'); + Notify.pushState.watch(); + + await redeclare('user_u1'); + + expect(reports(network), hasLength(1)); + expect(reports(network).single['external_id'], isNull); + }); + }); + + group('the wire shape', () { + test('a signed-in device posts the package shape, not a second one', + () async { + final FakeNetworkDriver network = Http.fake(); + useDriver(); + + await declareIdentity(); + + expect(reports(network), hasLength(1)); + final Map body = reports(network).single; + expect(body.keys.toSet(), { + 'external_id', + 'subscription_id', + 'reachability', + 'captured_at', + }); + expect(body['reachability'], 'on'); + expect(body['external_id'], 'user_u1'); + expect(body['subscription_id'], 'sub-phone'); + expect(DateTime.tryParse(body['captured_at'] as String), isNotNull); + }); + + test('a sign-out names this device, and nothing else about it', () async { + // The person comes from the SESSION on the server side, so a body naming + // one would be a second, weaker answer to a question the token settles. + final FakeNetworkDriver network = Http.fake(); + useDriver(); + await declareIdentity(); + + await Notify.pushState.release(); + + expect(releases(network), hasLength(1)); + expect(releases(network).single, { + 'subscription_id': 'sub-phone', + }); + }); + }); + + group('the memo goes with the session', () { + test('a device whose state has not moved does not post again', () async { + final FakeNetworkDriver network = Http.fake(); + useDriver(); + + await declareIdentity(); + await redeclare('user_u1'); + + expect(reports(network), hasLength(1)); + }); + + test('a report the server refused is made again, not remembered', () async { + final FakeNetworkDriver network = Http.fake({ + reportPath: Http.response({'message': 'nope'}, 500), + }); + useDriver(); + + await declareIdentity(); + await redeclare('user_u1'); + + expect(reports(network), hasLength(2)); + }); + + test('a sign-out lets the next person report an identical device state', + () async { + // Two people share one handset, and a failing SDK leaves the device + // carrying nobody for both, so their states are byte-identical. The + // reset hangs off `AuthLogout`, which every sign-out path dispatches. + final FakeNetworkDriver network = Http.fake(); + useDriver(_ReportingPushDriver()..failLogin = true); + Auth.fake(); + Notify.pushState.watch(); + + signIn('u1'); + await redeclare('user_u1'); + + await Auth.logout(); + await pumpEventQueue(); + + signIn('u2'); + await redeclare('user_u2'); + + expect(reports(network), hasLength(2)); + }); + + test('the next person on a shared device reports for themselves', () async { + final FakeNetworkDriver network = Http.fake(); + useDriver(); + await declareIdentity(); + + await Auth.logout(); + await Notify.logoutPush(); + await pumpEventQueue(); + + signIn('u2'); + await redeclare('user_u2'); + + expect(reports(network), hasLength(2)); + expect(reports(network).last['external_id'], 'user_u2'); + }); + + test('forget() lets an identical state be reported again', () async { + final FakeNetworkDriver network = Http.fake(); + useDriver(); + await declareIdentity(); + + Notify.pushState.forget(); + await redeclare('user_u1'); + + expect(reports(network), hasLength(2)); + }); + + test('a signed-out device reports nothing', () async { + final FakeNetworkDriver network = Http.fake(); + useDriver(); + await declareIdentity(); + + await Auth.logout(); + await Notify.logoutPush(); + await pumpEventQueue(); + + expect(reports(network), hasLength(1)); + }); + }); + + group('a device with no push driver at all', () { + test('a signed-in session posts exactly one report, marked unavailable', + () async { + // The package emits no reconcile pass for a driver-less build, so + // without this the device reports nothing for the whole session. + final FakeNetworkDriver network = Http.fake(); + signIn('u1'); + Notify.pushState.watch(); + await pumpEventQueue(); + + expect(reports(network), hasLength(1)); + expect(reports(network).single['reachability'], 'unavailable'); + }); + + test('a second notifier bump posts nothing, the memo holds', () async { + final FakeNetworkDriver network = Http.fake(); + signIn('u1'); + Notify.pushState.watch(); + await pumpEventQueue(); + + Auth.stateNotifier.value++; + await pumpEventQueue(); + + expect(reports(network), hasLength(1)); + }); + + test('a signed-out session posts nothing', () async { + final FakeNetworkDriver network = Http.fake(); + Auth.fake(); + Notify.pushState.watch(); + await pumpEventQueue(); + + expect(reports(network), isEmpty); + }); + + test('a sign-in after the watch was armed signed-out reports', () async { + final FakeNetworkDriver network = Http.fake(); + Auth.fake(); + Notify.pushState.watch(); + await pumpEventQueue(); + + // Through the same guard, so the bump lands on the notifier the watch + // is listening to, which is what a real sign-in does. + await Auth.login( + {'token': 'token-u1'}, + makeUser('u1'), + ); + await pumpEventQueue(); + + expect(reports(network), hasLength(1)); + expect(reports(network).single['reachability'], 'unavailable'); + }); + }); + + group('the device change streams are watched on their own', () { + test( + 'a driver attached after the watch is armed reports a revoked ' + 'permission', () async { + final FakeNetworkDriver network = Http.fake(); + signIn('u1'); + Notify.pushState.watch(); + await pumpEventQueue(); + + final _ReportingPushDriver driver = useDriver(); + await pumpEventQueue(); + + driver.changePermission(PushPermissionState.denied); + await pumpEventQueue(); + + expect(reports(network), hasLength(2)); + expect(reports(network).first['reachability'], 'unavailable'); + expect(reports(network).last['reachability'], 'blocked'); + }); + + test( + 'once a driver attaches, the next reconcile pass reports the real ' + 'state', () async { + final FakeNetworkDriver network = Http.fake(); + await declareIdentity(); + + expect(reports(network), hasLength(1)); + expect(reports(network).single['reachability'], 'unavailable'); + + useDriver(); + await Notify.manager.reconcilePushIdentity(); + await pumpEventQueue(); + + expect(reports(network), hasLength(2)); + expect(reports(network).last['reachability'], 'on'); + expect(reports(network).last['external_id'], 'user_u1'); + expect(reports(network).last['subscription_id'], 'sub-phone'); + }); + + test('a driver that was already there is watched from the arming read', + () async { + final FakeNetworkDriver network = Http.fake(); + signIn('u1'); + final _ReportingPushDriver driver = useDriver(); + await pumpEventQueue(); + + Notify.pushState.watch(); + driver.changePermission(PushPermissionState.denied); + await pumpEventQueue(); + + expect(reports(network), hasLength(1)); + expect(reports(network).single['reachability'], 'blocked'); + }); + + test('a revoked permission reports itself with nobody asking', () async { + final FakeNetworkDriver network = Http.fake(); + final _ReportingPushDriver driver = useDriver(); + await declareIdentity(); + + driver.changePermission(PushPermissionState.denied); + await pumpEventQueue(); + + expect(reports(network), hasLength(2)); + expect(reports(network).last['reachability'], 'blocked'); + }); + + test('a swapped subscription reports itself', () async { + final FakeNetworkDriver network = Http.fake(); + final _ReportingPushDriver driver = useDriver(); + await declareIdentity(); + + driver.changeSubscription('sub-reinstalled'); + await pumpEventQueue(); + + expect(reports(network), hasLength(2)); + expect(reports(network).last['subscription_id'], 'sub-reinstalled'); + }); + + test('arming twice reports a pass once', () async { + final FakeNetworkDriver network = Http.fake(); + useDriver(); + signIn('u1'); + Notify.pushState.watch(); + Notify.pushState.watch(); + + await redeclare('user_u1'); + + expect(reports(network), hasLength(1)); + }); + + test('the reporter creates no periodic timer', () { + // A poll would put one write per device per interval on the backend for + // a fact that changes when the OS says so. Structural, because proving + // an absence by advancing a fake clock hangs on the real event queue + // every helper here waits on. + final String source = File( + 'lib/src/support/push_state_reporter.dart', + ).readAsStringSync(); + + expect(source, isNot(contains('Timer.periodic'))); + }); + }); + + group('release', () { + test('a sign-in followed by a release posts once', () async { + final FakeNetworkDriver network = Http.fake(); + useDriver(); + await declareIdentity(); + + await Notify.pushState.release(); + await Notify.pushState.release(); + + expect(releases(network), hasLength(1)); + }); + + test('two releases in the same breath post once', () async { + final FakeNetworkDriver network = Http.fake(); + useDriver(); + await declareIdentity(); + + await Future.wait(>[ + Notify.pushState.release(), + Notify.pushState.release(), + ]); + + expect(releases(network), hasLength(1)); + }); + + test('a sign-out with no device to name posts nothing', () async { + // A build with no push at all reports `unavailable` with no subscription + // id and has therefore never vouched for anybody. + final FakeNetworkDriver network = Http.fake(); + await declareIdentity(); + + await Notify.pushState.release(); + + expect(releases(network), isEmpty); + }); + + test('a driver that has stopped answering still releases its row', + () async { + final FakeNetworkDriver network = Http.fake(); + final _ReportingPushDriver driver = useDriver(); + await declareIdentity(); + + // Assigned rather than announced, which would fire a report of its own. + driver.subscriptionId = null; + + await Notify.pushState.release(); + + expect(releases(network).single['subscription_id'], 'sub-phone'); + }); + + test('a signed-out session releases nothing', () async { + final FakeNetworkDriver network = Http.fake(); + useDriver(); + await declareIdentity(); + await Auth.logout(); + + await Notify.pushState.release(); + + expect(releases(network), isEmpty); + }); + + test('a released device is reported from scratch, not remembered', + () async { + final FakeNetworkDriver network = Http.fake(); + useDriver(); + await declareIdentity(); + + await Notify.pushState.release(); + await redeclare('user_u1'); + + expect(reports(network), hasLength(2)); + expect(reports(network).last['subscription_id'], 'sub-phone'); + }); + + test('a device reported again after a release can be released again', + () async { + final FakeNetworkDriver network = Http.fake(); + useDriver(); + await declareIdentity(); + + await Notify.pushState.release(); + await redeclare('user_u1'); + await Notify.pushState.release(); + + expect(releases(network), hasLength(2)); + }); + + test('a refused release leaves the memo describing what the server has', + () async { + final FakeNetworkDriver network = Http.fake({ + releasePath: Http.response({'message': 'nope'}, 500), + }); + useDriver(); + await declareIdentity(); + + await Notify.pushState.release(); + await redeclare('user_u1'); + + expect(releases(network), hasLength(1)); + expect(reports(network), hasLength(1)); + }); + + test('a refused release can be tried again', () async { + final FakeNetworkDriver network = Http.fake({ + releasePath: Http.response({'message': 'nope'}, 500), + }); + useDriver(); + await declareIdentity(); + + await Notify.pushState.release(); + await Notify.pushState.release(); + + expect(releases(network), hasLength(2)); + }); + }); +} From 32ebbd1082338129baae92a1a9a01710e580e83d Mon Sep 17 00:00:00 2001 From: Anilcan Cakir Date: Sun, 27 Sep 2026 13:01:06 +0300 Subject: [PATCH 2/2] fix: keep PushStateReporter off until both endpoints are named, document Notify.pushState in the README, raise the magic floor to ^0.0.22 --- CHANGELOG.md | 7 ++++-- README.md | 25 ++++++++++++++++++++++ doc/architecture/notification-manager.md | 2 +- lib/src/support/push_state_reporter.dart | 20 ++++++++++++++--- pubspec.yaml | 5 +++-- test/support/push_state_reporter_test.dart | 22 ++++++++++++++++--- 6 files changed, 70 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c1d45fb..16ac5de 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,9 +3,9 @@ ## [Unreleased] ### Added -- **`PushStateReporter`, reached as `Notify.pushState`: the device tells its backend whether a push can reach it, and withdraws that on sign-out.** Moved here from the one app that had written it, so every adopter gets the same answer to "who keeps receiving pages after sign-out". `watch()` reports `PushDeliverySnapshot.toMap()` verbatim after every `onPushIdentityReconciled` pass towards the signed-in person, whenever the attached driver's permission or subscription stream moves (attached late through `onPushDriverAttached`), and once as `unavailable` off `Auth.stateNotifier` for a build with no driver at all, since no pass is emitted there. A memo of the last ACCEPTED state stops repeats, a refused post is retried on the next event, and the memo is forgotten on every `AuthLogout`. `release()` posts `{subscription_id}` alone for this device (the live read, falling back to the last accepted report's id), must run before `Auth.logout()` drops the token, joins a release already in flight and does not post a subscription it already released, so calling it twice is safe. `isConfigured` lets a starter kit's sign-out path skip it. `forget()` clears the memo. +- **`PushStateReporter`, reached as `Notify.pushState`: the device tells its backend whether a push can reach it, and withdraws that on sign-out.** Moved here from the one app that had written it, so every adopter gets the same answer to "who keeps receiving pages after sign-out". `watch()` reports `PushDeliverySnapshot.toMap()` verbatim after every `onPushIdentityReconciled` pass towards the signed-in person, whenever the attached driver's permission or subscription stream moves (attached late through `onPushDriverAttached`), and once as `unavailable` off `Auth.stateNotifier` for a build with no driver at all, since no pass is emitted there. A memo of the last ACCEPTED state stops repeats, a refused post is retried on the next event, and the memo is forgotten on every `AuthLogout`. `release()` posts `{subscription_id}` alone for this device (the live read, falling back to the last accepted report's id), must run before `Auth.logout()` drops the token, joins a release already in flight and does not post a subscription it already released, so calling it twice is safe. `isConfigured` reads true only when both endpoints are named, and lets a starter kit's sign-out path skip the release; a report path without a release path keeps the whole reporter off and logs why, since a device no sign-out can withdraw would keep vouching for whoever left it last. `forget()` clears the memo. - **Off by default, with no default path.** `notifications.push_state.report_path` and `release_path` name the endpoints and are null in the install stub, so an app whose backend has no such route sends nothing. `notifications.push_state.external_id_prefix` (default `user_`) is how the reporter recognises a pass for the signed-in person, ``; it has to match what the app passes to `Notify.initializePush`. `NotificationManager.forgetDrivers()` resets the reporter too, because its watch lives on the manager's streams. The backend contract is documented in `doc/basics/laravel-backend-setup.md`. (`lib/src/support/push_state_reporter.dart`, `lib/src/notification_manager.dart`, `lib/src/facades/notify.dart`, `lib/magic_notifications.dart`, `assets/stubs/install/notification_config.stub`, `test/support/push_state_reporter_test.dart`, `doc/basics/shipping-push.md`, `doc/basics/laravel-backend-setup.md`) + **Off by default, with no default path.** `notifications.push_state.report_path` and `release_path` name the endpoints and are null in the install stub, so an app whose backend has no such route sends nothing. `notifications.push_state.external_id_prefix` (default `user_`) is how the reporter recognises a pass for the signed-in person, ``; it has to match what the app passes to `Notify.initializePush`. `NotificationManager.forgetDrivers()` resets the reporter too, because its watch lives on the manager's streams. The backend contract is documented in `doc/basics/laravel-backend-setup.md`. (`lib/src/support/push_state_reporter.dart`, `lib/src/notification_manager.dart`, `lib/src/facades/notify.dart`, `lib/magic_notifications.dart`, `assets/stubs/install/notification_config.stub`, `test/support/push_state_reporter_test.dart`, `doc/basics/shipping-push.md`, `doc/basics/laravel-backend-setup.md`, `README.md`) - **A push permission soft prompt component**, at `lib/src/ui/components/push_prompt/` following the `notification_dropdown/` folder shape (component, recipe, preview, index). `PushPrompt` renders the four presentations `pushPromptAdvice` can produce (`unavailable`, `blocked` with or without a settings route back, `off` asking or already declined, `on`) from `reachability`/`action`/`declined`/`busy` and reports two callbacks; it touches no platform API itself. `PushPromptHost` wires it to `Notify.manager.pushPromptAdvice()`, re-reading on the attached driver's `onPermissionChanged` and `onIdentityChanged` streams so a grant landing OUT OF BAND still clears the row, and on `NotificationManager.onPushDriverAttached` so a driver that resolves after the widget mounts is picked up too rather than leaving the row on `unavailable` until it remounts. That is the common path, since `requestPermission()` on an already-denied device opens the platform settings page rather than raising a dialog. `PushOffNotice` is a quiet, tappable shell marker for wherever a host's own chrome wants one, following the same two device streams and the same late-driver signal. @@ -15,6 +15,9 @@ Ships no translation catalogue of its own, matching every other `notifications.*` string in this package: the full `notifications.push_prompt.*` key list with English reference copy is now in `doc/basics/preferences.md`. The two colour roles it needs beyond the 17-key semantic alias contract (`up`/`on` and `degraded`/`blocked`) map onto `success` and `warning`, the two roles the contract ships with no `-container` tint of their own; the tile and its glyph go solid (`bg-success` / `bg-warning` with a literal `text-white`) rather than inventing one, following the pairing `toast.recipe.dart` already established for those roles. (`lib/src/ui/components/push_prompt/`, `lib/magic_notifications.dart`, `lib/src/notification_manager.dart`, `test/ui/components/push_prompt_test.dart`, `doc/basics/preferences.md`, `doc/architecture/notification-manager.md`) +### Changed +- **`magic` floor moves `^0.0.16` to `^0.0.22`.** `PushStateReporter` calls `Event.listenAny`, which magic 0.0.22 introduces, so an adopter on an older magic now gets a version-solve error instead of a compile error inside this package. (`pubspec.yaml`) + ## [0.3.4] - 2026-09-22 ### Changed diff --git a/README.md b/README.md index fa0220a..fbbefeb 100644 --- a/README.md +++ b/README.md @@ -224,12 +224,37 @@ four presentations and the translation keys a host has to add. identity reconcile pass (login, logout, or a driver attaching later): see [Push Identity Reconcile Outcomes](doc/architecture/notification-manager.md#identity-reconciled). +### Tell Your Backend Whether the Device Can Be Paged + +OneSignal accepts a push for a subscription that is denied, opted out or gone, +and reports nothing back. `Notify.pushState` posts the device's own +reachability to your backend whenever it changes, and withdraws it on +sign-out. It stays off until both endpoints are configured (there is no +default path): + +```dart +// config: 'push_state': { +// 'report_path': '/devices/push-state', +// 'release_path': '/devices/push-state/release', +// }, + +// In a provider's boot(), once auth is registered. +Notify.pushState.watch(); +``` + +The release has to run before `Auth.logout()` drops the token (see below). The +server half is in +[Laravel Backend Setup](doc/basics/laravel-backend-setup.md#onesignal-push); +[Shipping Push](doc/basics/shipping-push.md#telling-your-backend-whether-the-device-can-be-paged) +covers when a report is sent. + ### Clean Up on Logout ```dart Future onLogout() async { Notify.stopRealtime(); Notify.stopPolling(); + if (Notify.pushState.isConfigured) await Notify.pushState.release(); await Notify.logoutPush(); } ``` diff --git a/doc/architecture/notification-manager.md b/doc/architecture/notification-manager.md index ce200a1..e835cab 100644 --- a/doc/architecture/notification-manager.md +++ b/doc/architecture/notification-manager.md @@ -229,7 +229,7 @@ failure: 2. **Connects only if nothing is connected.** `Echo.connect()` is not idempotent in magic's Reverb driver: it assigns a fresh channel without closing the previous one, so a redundant call opens a second WebSocket and leaks the first. - This is why `magic ^0.0.6` is the floor; `Echo.connection` is the accessor that + This is why the `magic` floor cannot sit below 0.0.6; `Echo.connection` is the accessor that makes the check possible. 3. **Listens for `notification.created` exactly once.** A second `listen()` for one event name REPLACES the earlier handler rather than adding to it, so diff --git a/lib/src/support/push_state_reporter.dart b/lib/src/support/push_state_reporter.dart index e461ad3..ff979ea 100644 --- a/lib/src/support/push_state_reporter.dart +++ b/lib/src/support/push_state_reporter.dart @@ -109,11 +109,15 @@ class PushStateReporter { /// Stops the `AuthLogout` listener [watch] installs. void Function()? _stopForgettingOnLogout; - /// Whether this app names a report endpoint at all. + /// Whether this app names both endpoints. + /// + /// Both or neither: a device reported with no release path configured could + /// never be withdrawn at sign-out, and would keep vouching for whoever + /// signed out of it last. [watch] logs an app that names only one. /// /// A starter kit asks this before calling [release] from its sign-out path, /// so an app without the backend half pays nothing on the way out. - bool get isConfigured => _reportPath != null; + bool get isConfigured => _reportPath != null && _releasePath != null; /// Starts keeping the backend's picture of this device current. /// @@ -142,7 +146,17 @@ class PushStateReporter { /// registered, from a provider's `boot()`. void watch() { _stopWatching(); - if (!isConfigured) return; + if (!isConfigured) { + if (_reportPath != null || _releasePath != null) { + NotificationLog.error( + 'Push state reporting stays off: set both ' + 'notifications.push_state.report_path and ' + 'notifications.push_state.release_path, or neither', + ); + } + + return; + } _reconciledPasses = _manager.onPushIdentityReconciled.listen( _reportIfReconciledForCurrentUser, diff --git a/pubspec.yaml b/pubspec.yaml index baedddf..fbc1d82 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -26,8 +26,9 @@ dependencies: # matters because `Echo.connection` does not exist below 0.0.6, and without # it a solver may hand this package a magic that lacks the accessor the # realtime notification path needs to avoid a second WebSocket on an - # already-open connection. - magic: ^0.0.16 + # already-open connection. It sits at 0.0.22 because `PushStateReporter` + # calls `Event.listenAny`, which does not exist below it. + magic: ^0.0.22 # 0.0.15 is the requirement: `XcodeProjectEditor.setEntitlementsPaths`, # which the iOS installer step calls to point Release at its own # entitlements file, does not exist below it. The floor names 0.0.16, the diff --git a/test/support/push_state_reporter_test.dart b/test/support/push_state_reporter_test.dart index 3e61f7f..28ee70f 100644 --- a/test/support/push_state_reporter_test.dart +++ b/test/support/push_state_reporter_test.dart @@ -236,19 +236,35 @@ void main() { ); }); - test('an absent release path releases nothing', () async { + test('an absent release path reads as not configured', () { Config.set('notifications.push_state.release_path', null); + + expect(Notify.pushState.isConfigured, isFalse); + }); + + test('a report path without a release path reports nothing, and says so', + () async { + // Reporting a device that no sign-out can withdraw would leave it + // vouching for whoever signed out last, which is worse than silence. + Config.set('notifications.push_state.release_path', null); + final FakeLogManager log = Log.fake(); + addTearDown(Log.unfake); final FakeNetworkDriver network = Http.fake(); useDriver(); await declareIdentity(); await Notify.pushState.release(); - expect(reports(network), hasLength(1)); expect( - network.recorded.where((entry) => entry.$1.url.endsWith('release')), + network.recorded.where((entry) => entry.$1.url.contains('push-state')), isEmpty, ); + expect( + log.entries.where( + (FakeLogEntry entry) => entry.message.contains('release_path'), + ), + hasLength(1), + ); }); test('the report is posted to the configured path, verbatim', () async {