diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index ef2b373de..e2ad0b8e7 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -88,6 +88,21 @@
+
+
diff --git a/android/app/src/main/kotlin/com/exptech/dpip/PlainChannelsChannel.kt b/android/app/src/main/kotlin/com/exptech/dpip/PlainChannelsChannel.kt
index 1a3adec66..eee6f8591 100644
--- a/android/app/src/main/kotlin/com/exptech/dpip/PlainChannelsChannel.kt
+++ b/android/app/src/main/kotlin/com/exptech/dpip/PlainChannelsChannel.kt
@@ -23,6 +23,12 @@ import io.flutter.plugin.common.MethodChannel
* NotificationManager. An existing channel is never touched — user tuning
* survives ordinary launches, and the Dart-side catalogue-version gate is
* what drives delete + re-create when the sound files themselves change.
+ *
+ * `list` and `delete` exist for the other end of a channel's life: the ids
+ * earlier builds created and this one dropped, which Android keeps until the
+ * app deletes them. *Which* ids are retired is decided in Dart
+ * (`RetiredChannels`), where it is tested; this side only reports what exists
+ * and deletes what it is told to.
*/
class PlainChannelsChannel(private val context: Context) :
MethodChannel.MethodCallHandler {
@@ -39,13 +45,40 @@ class PlainChannelsChannel(private val context: Context) :
result.success(ensure(channels))
}
+ "list" -> result.success(manager().notificationChannels.map { it.id })
+
+ "delete" -> {
+ val ids = call.argument>("ids")
+ if (ids == null || ids.any { it !is String || it.isBlank() }) {
+ result.error("invalid_ids", "ids must be non-empty strings", null)
+ return
+ }
+ result.success(delete(ids.filterIsInstance()))
+ }
+
else -> result.notImplemented()
}
}
+ private fun manager() =
+ context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
+
+ /** Deletes each of [ids] that exists; returns how many did. */
+ private fun delete(ids: List): Int {
+ val manager = manager()
+ var deleted = 0
+ for (id in ids) {
+ // Checked first so a repeat run reports 0 rather than a count of
+ // deletes that did nothing — the log line says what changed.
+ if (manager.getNotificationChannel(id) == null) continue
+ manager.deleteNotificationChannel(id)
+ deleted++
+ }
+ return deleted
+ }
+
private fun ensure(channels: List>): Int {
- val manager =
- context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
+ val manager = manager()
var created = 0
for (entry in channels) {
val id = entry["id"] as? String ?: continue
diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj
index 34cff7394..88ca49102 100644
--- a/ios/Runner.xcodeproj/project.pbxproj
+++ b/ios/Runner.xcodeproj/project.pbxproj
@@ -43,6 +43,7 @@
AA0000000000000000000H02 /* NetworkPlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0000000000000000000H01 /* NetworkPlugin.swift */; };
AA0000000000000000000D02 /* DeviceInfoPlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0000000000000000000D01 /* DeviceInfoPlugin.swift */; };
NL0G00000000000000000002 /* NativeLogPlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = NL0G00000000000000000001 /* NativeLogPlugin.swift */; };
+ NS1K00000000000000000002 /* NotificationSettingsLinkPlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = NS1K00000000000000000001 /* NotificationSettingsLinkPlugin.swift */; };
NL0G00000000000000000004 /* NativeLogTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = NL0G00000000000000000003 /* NativeLogTests.swift */; };
LR0S00000000000000000002 /* LocationReportSender.swift in Sources */ = {isa = PBXBuildFile; fileRef = LR0S00000000000000000001 /* LocationReportSender.swift */; };
LR0S00000000000000000004 /* LocationReportSenderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = LR0S00000000000000000003 /* LocationReportSenderTests.swift */; };
@@ -170,6 +171,7 @@
AA0000000000000000000H01 /* NetworkPlugin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkPlugin.swift; sourceTree = ""; };
AA0000000000000000000D01 /* DeviceInfoPlugin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceInfoPlugin.swift; sourceTree = ""; };
NL0G00000000000000000001 /* NativeLogPlugin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativeLogPlugin.swift; sourceTree = ""; };
+ NS1K00000000000000000001 /* NotificationSettingsLinkPlugin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationSettingsLinkPlugin.swift; sourceTree = ""; };
NL0G00000000000000000003 /* NativeLogTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativeLogTests.swift; sourceTree = ""; };
LR0S00000000000000000001 /* LocationReportSender.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocationReportSender.swift; sourceTree = ""; };
LR0S00000000000000000003 /* LocationReportSenderTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocationReportSenderTests.swift; sourceTree = ""; };
@@ -454,6 +456,7 @@
AA0000000000000000000F01 /* ApnsTokenPlugin.swift */,
AA0000000000000000000D01 /* DeviceInfoPlugin.swift */,
NL0G00000000000000000001 /* NativeLogPlugin.swift */,
+ NS1K00000000000000000001 /* NotificationSettingsLinkPlugin.swift */,
7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */,
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */,
797A71348A49484748BB3224 /* MapSnapshotPlugin.swift */,
@@ -762,6 +765,7 @@
AA0000000000000000000F02 /* ApnsTokenPlugin.swift in Sources */,
AA0000000000000000000D02 /* DeviceInfoPlugin.swift in Sources */,
NL0G00000000000000000002 /* NativeLogPlugin.swift in Sources */,
+ NS1K00000000000000000002 /* NotificationSettingsLinkPlugin.swift in Sources */,
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */,
7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */,
071504DB0F1524DF85241961 /* MapSnapshotPlugin.swift in Sources */,
diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift
index 08fb8849a..78aa63f73 100644
--- a/ios/Runner/AppDelegate.swift
+++ b/ios/Runner/AppDelegate.swift
@@ -61,6 +61,8 @@ import UserNotifications
with: registry.registrar(forPlugin: "BackgroundExecutionPlugin")!)
WidgetSnapshotPlugin.register(with: registry.registrar(forPlugin: "WidgetSnapshotPlugin")!)
NativeLogPlugin.register(with: registry.registrar(forPlugin: "NativeLogPlugin")!)
+ NotificationSettingsLinkPlugin.register(
+ with: registry.registrar(forPlugin: "NotificationSettingsLinkPlugin")!)
// Re-post the launch notification the plugins just missed.
//
@@ -184,4 +186,15 @@ final class NotificationDelegateProxy: NSObject, UNUserNotificationCenterDelegat
wrapped.userNotificationCenter?(
center, didReceive: response, withCompletionHandler: completionHandler)
}
+
+ /// The "DPIP Notification Settings" row in iOS Settings — which exists
+ /// because `NotificationSettingsLinkPlugin` asked for it, so this one is
+ /// ours. Not forwarded: awesome (0.12.1) does not implement it, and a
+ /// wrapped delegate that one day did would open the page a second time.
+ func userNotificationCenter(
+ _ center: UNUserNotificationCenter,
+ openSettingsFor notification: UNNotification?
+ ) {
+ NotificationSettingsLinkPlugin.request()
+ }
}
diff --git a/ios/Runner/NotificationSettingsLinkPlugin.swift b/ios/Runner/NotificationSettingsLinkPlugin.swift
new file mode 100644
index 000000000..015dbc60f
--- /dev/null
+++ b/ios/Runner/NotificationSettingsLinkPlugin.swift
@@ -0,0 +1,95 @@
+import Flutter
+import UIKit
+import UserNotifications
+
+/// The link between iOS Settings → DPIP → Notifications and the app's own
+/// notification settings page.
+///
+/// iOS draws a "DPIP Notification Settings" row there only for an app that
+/// asked for `.providesAppNotificationSettings`, and tapping it calls the
+/// notification-centre delegate's `openSettingsFor`. Both halves are here.
+///
+/// **Asking.** awesome_notifications builds its authorization options from a
+/// fixed permission list that has no entry for this one, so the app adds it
+/// itself — once the user has already said yes. Asking while the status is
+/// `notDetermined` would show the system prompt outside the onboarding flow
+/// that owns it, and asking without `.provisional` while provisional would
+/// show the full prompt, so both are left alone. An authorized app asking
+/// again is not prompted: iOS records the extra option and answers at once.
+///
+/// **Answering.** `NotificationDelegateProxy` receives `openSettingsFor` and
+/// calls [request]. The request is a flag, not a navigation: on a cold launch
+/// from Settings, iOS can call the delegate before Dart has a router, so the
+/// flag waits until Dart takes it — either when nudged with `open`, or at
+/// startup and on every resume. `take` reads and clears the flag in one step
+/// on the main thread, so however those race, the page opens once.
+public class NotificationSettingsLinkPlugin: NSObject, FlutterPlugin {
+ /// Main thread only, like everything else here.
+ private static var pending = false
+ private static var channel: FlutterMethodChannel?
+
+ public static func register(with registrar: FlutterPluginRegistrar) {
+ let channel = FlutterMethodChannel(
+ name: "com.exptech.dpip/notification_settings_link",
+ binaryMessenger: registrar.messenger())
+ registrar.addMethodCallDelegate(NotificationSettingsLinkPlugin(), channel: channel)
+ NotificationSettingsLinkPlugin.channel = channel
+ }
+
+ /// Records that iOS asked for the app's notification settings, and nudges
+ /// Dart to come and take it.
+ static func request() {
+ DispatchQueue.main.async {
+ pending = true
+ // No handler yet is fine: the flag is what Dart reads at startup.
+ channel?.invokeMethod("open", arguments: nil)
+ }
+ }
+
+ /// Reads and clears the request flag.
+ static func take() -> Bool {
+ let was = pending
+ pending = false
+ return was
+ }
+
+ public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) {
+ switch call.method {
+ case "take":
+ result(NotificationSettingsLinkPlugin.take())
+ case "provide":
+ NotificationSettingsLinkPlugin.provide { result($0) }
+ default:
+ result(FlutterMethodNotImplemented)
+ }
+ }
+
+ /// Asks iOS for the Settings row, if the user has already allowed
+ /// notifications and the row is not there yet. Completes on the main thread
+ /// with whether the row is now provided.
+ static func provide(completion: @escaping (Bool) -> Void) {
+ let center = UNUserNotificationCenter.current()
+ center.getNotificationSettings { settings in
+ guard settings.authorizationStatus == .authorized else {
+ DispatchQueue.main.async { completion(false) }
+ return
+ }
+ if settings.providesAppNotificationSettings {
+ DispatchQueue.main.async { completion(true) }
+ return
+ }
+ // The options the app already holds, plus the one it lacks. Asking for
+ // fewer would not revoke anything, but naming them keeps this request
+ // the same shape as the one the user answered. Not `.criticalAlert`: if
+ // that grant is still undecided, including it would prompt for it here.
+ center.requestAuthorization(
+ options: [.alert, .sound, .badge, .providesAppNotificationSettings]
+ ) { granted, error in
+ if let error {
+ NSLog("notification settings link request failed: \(error.localizedDescription)")
+ }
+ DispatchQueue.main.async { completion(granted && error == nil) }
+ }
+ }
+ }
+}
diff --git a/ios/Runner/Runner.entitlements b/ios/Runner/Runner.entitlements
index 16e36bcf7..724bd7821 100644
--- a/ios/Runner/Runner.entitlements
+++ b/ios/Runner/Runner.entitlements
@@ -6,6 +6,8 @@
development
com.apple.developer.usernotifications.critical-alerts
+ com.apple.developer.usernotifications.time-sensitive
+
com.apple.security.application-groups
group.com.exptech.dpip.dpip.widgets
diff --git a/ios/Runner/RunnerRelease.entitlements b/ios/Runner/RunnerRelease.entitlements
index b104f9b81..58caf43ce 100644
--- a/ios/Runner/RunnerRelease.entitlements
+++ b/ios/Runner/RunnerRelease.entitlements
@@ -16,6 +16,15 @@
and provisioning profile must both carry it or signing fails outright. -->
com.apple.developer.usernotifications.critical-alerts
+
+ com.apple.developer.usernotifications.time-sensitive
+
com.apple.security.application-groups
group.com.exptech.dpip.dpip.widgets
diff --git a/lib/app/app.dart b/lib/app/app.dart
index 98faf4cec..dd54e95fb 100644
--- a/lib/app/app.dart
+++ b/lib/app/app.dart
@@ -208,6 +208,15 @@ class _AppServicesHostState extends State<_AppServicesHost>
stagger: const Duration(milliseconds: 250),
);
NotificationTaps.drainPending();
+ // The iOS Settings link, including a tap that launched the app — after
+ // the first frame for the same reason as the drain above.
+ unawaited(
+ widget.notificationService.settingsLink.listen(
+ () => routeNotificationSettingsLink(
+ onboardingComplete: widget.onboarding.isComplete,
+ ),
+ ),
+ );
// Permissions belong to onboarding — only run the permission-dependent
// setup once it's complete (immediately for returning users).
if (widget.onboarding.isComplete) _onReady();
@@ -313,6 +322,7 @@ class _AppServicesHostState extends State<_AppServicesHost>
@override
void dispose() {
NotificationTaps.onTap = null;
+ widget.notificationService.settingsLink.stop();
widget.onboarding.removeListener(_onOnboardingChanged);
unawaited(_tokenChanges?.cancel());
widget.deviceRegistration?.dispose();
diff --git a/lib/app/router/notification_routes.dart b/lib/app/router/notification_routes.dart
index 3a574bbbe..00827b511 100644
--- a/lib/app/router/notification_routes.dart
+++ b/lib/app/router/notification_routes.dart
@@ -255,3 +255,35 @@ String _unmapped(String? channelKey) {
Log.warning('Notification channel not mapped to a route: $channelKey');
return AppRoutes.home;
}
+
+/// The slice of the router the iOS Settings link needs — [GoRouter.pushNamed].
+typedef NotificationSettingsNavigator = void Function(String name);
+
+/// Opens the notification settings page for the "DPIP Notification Settings"
+/// row in iOS Settings → DPIP → Notifications.
+///
+/// Pushed rather than gone to, so Back returns to whatever the user was doing
+/// — iOS sends them here mid-session as readily as at launch. Not pushed again
+/// when the page is already on top, because a second tap on the row should
+/// land on the same page, not stack another copy over it. Not opened at all
+/// before onboarding is finished: the router would send it straight back to
+/// onboarding, which is the page the user is already on.
+///
+/// [push] and [currentRouteName] are injectable for tests; they default to
+/// the app router.
+void routeNotificationSettingsLink({
+ required bool onboardingComplete,
+ NotificationSettingsNavigator? push,
+ String? Function()? currentRouteName,
+}) {
+ if (!onboardingComplete) {
+ Log.info('Notification settings link: onboarding not finished, ignored');
+ return;
+ }
+ final current = (currentRouteName ?? () => appRouter.state.name)();
+ if (current == AppRoutes.notifySettings) {
+ Log.info('Notification settings link: already on the page');
+ return;
+ }
+ (push ?? (name) => appRouter.pushNamed(name))(AppRoutes.notifySettings);
+}
diff --git a/lib/core/notifications/notification_channels.dart b/lib/core/notifications/notification_channels.dart
index dca214b8e..75ca38ea0 100644
--- a/lib/core/notifications/notification_channels.dart
+++ b/lib/core/notifications/notification_channels.dart
@@ -10,6 +10,10 @@ import 'package:flutter/material.dart';
/// a channel's settings after first creation, so [version] is bumped whenever a
/// definition changes to force a re-create on next launch. (Adding a brand-new
/// channel needs no bump — `initialize` creates it without touching the others.)
+///
+/// Renaming or dropping a channel is not finished here: Android keeps the old
+/// one on every upgraded device until the app deletes it, so its key goes into
+/// `RetiredChannels.keys` (`retired_channels.dart`).
abstract final class NotificationChannels {
const NotificationChannels._();
@@ -466,8 +470,11 @@ enum NotificationBehaviour {
///
/// That caveat is the whole difference from [overrides] and it is not
/// cosmetic. awesome maps importance to an iOS interruption level in
- /// `NotificationBuilder.setImportance` — High/Max become `.timeSensitive`,
- /// Default becomes `.active` — and only the `.critical` level a
+ /// `NotificationBuilder.setImportance` — High/Max become `.timeSensitive`
+ /// (which iOS honours only because the Runner entitlements carry
+ /// `usernotifications.time-sensitive`; without it the level silently drops
+ /// to `.active` and a Focus holds the alert), Default becomes `.active` —
+ /// and only the `.critical` level a
/// `criticalAlerts` channel gets bypasses the mute switch. The code that
/// attaches the sound is otherwise the *same branch* for both, so a channel
/// in this tier is not missing its sound on a muted phone: the sound is
diff --git a/lib/core/notifications/notification_service.dart b/lib/core/notifications/notification_service.dart
index 26cb2c29a..d3b72774b 100644
--- a/lib/core/notifications/notification_service.dart
+++ b/lib/core/notifications/notification_service.dart
@@ -10,8 +10,10 @@ import 'package:dpip/core/permissions/system_settings.dart';
import 'package:dpip/core/notifications/notification_channels.dart';
import 'package:dpip/core/notifications/foreground_eew_announcement_gate.dart';
import 'package:dpip/core/notifications/notification_samples.dart';
+import 'package:dpip/core/notifications/notification_settings_link.dart';
import 'package:dpip/core/notifications/notification_taps.dart';
import 'package:dpip/core/notifications/plain_channels.dart';
+import 'package:dpip/core/notifications/retired_channels.dart';
import 'package:dpip/core/settings/setting_keys.dart';
import 'package:dpip/core/settings/settings_store.dart';
import 'package:flutter/foundation.dart';
@@ -38,13 +40,19 @@ class NotificationService {
NotificationService(
this._settings, {
ForegroundEewAnnouncementGate? foregroundEewGate,
- }) : foregroundEewGate =
- foregroundEewGate ?? ForegroundEewAnnouncementGate() {
+ NotificationSettingsLink? settingsLink,
+ }) : foregroundEewGate = foregroundEewGate ?? ForegroundEewAnnouncementGate(),
+ settingsLink = settingsLink ?? NotificationSettingsLink() {
_foregroundEewGate = this.foregroundEewGate;
}
final SettingsStore _settings;
+ /// The link back into the app from iOS Settings → DPIP → Notifications.
+ /// Requested here, alongside the permission it depends on; routed by the
+ /// app layer, which owns the page it opens.
+ final NotificationSettingsLink settingsLink;
+
/// Sequences foreground EEW speech before the notification channel sound.
/// Background and terminated delivery bypass this object entirely.
final ForegroundEewAnnouncementGate foregroundEewGate;
@@ -118,6 +126,10 @@ class NotificationService {
onNotificationDisplayedMethod: onNotificationDisplayed,
);
await _initMessaging(channels);
+ // Every launch, not only on a fresh grant: an install that allowed
+ // notifications before this existed never passes through
+ // [requestPermission] again.
+ unawaited(settingsLink.provide());
}
/// One line per launch saying whether push actually came up.
@@ -163,7 +175,10 @@ class NotificationService {
Future requestPermission() async {
final already = await AwesomeNotifications().isNotificationAllowed();
Log.info('permission: notifications, already allowed = $already');
- if (already) return PermissionOutcome.granted;
+ if (already) {
+ unawaited(settingsLink.provide());
+ return PermissionOutcome.granted;
+ }
// Ask the plugin to prompt **only** when iOS has not decided yet.
//
@@ -202,6 +217,10 @@ class NotificationService {
},
);
Log.info('permission: notifications request -> $granted');
+ // awesome's permission list cannot ask for the iOS Settings row, so it is
+ // requested as soon as there is a grant for it to ride on.
+ if (granted) unawaited(settingsLink.provide());
+
if (granted) {
// Permission is usually granted well after launch (onboarding) — by then
// the launch-time token fetch may have found no APNs token yet. Retry now
@@ -438,6 +457,8 @@ class NotificationService {
// channels this launch. Mirror the catalogue under plain keys last, after
// every purge and re-registration above has settled.
await PlainChannels.ensure(channels);
+ // Last, so nothing above can re-create what it deletes.
+ await RetiredChannels().sweep();
return (total: channels.length, rejected: rejected.length);
}
@@ -689,8 +710,13 @@ NotificationContent? contentFromData(
(data['channel'] as String?) ??
nestedField('channelKey') ??
_fallbackChannelKey;
- final id =
- _asNotificationId(data['id']) ?? _asNotificationId(nested?['id']) ?? 0;
+ final id = _resolveNotificationId(
+ flat: data['id'],
+ nested: nested?['id'],
+ channelKey: channelKey,
+ title: title,
+ body: body,
+ );
final idText = (data['id'] as String?) ?? nestedField('id');
return NotificationContent(
id: id,
@@ -722,23 +748,96 @@ Map? _nestedContent(Map data) {
}
}
-/// Coerces a payload id to the form awesome accepts.
+/// [data] as `createNotificationFromJsonData` should receive it: the same
+/// payload, with `content.id` replaced by one awesome will accept.
+///
+/// The terminated-app path hands the producer's payload to awesome whole, and
+/// awesome's own `createNotification` throws on an id outside the signed
+/// 32-bit range — which `createNotificationFromJsonData` catches and turns into
+/// a bare `false`. The producer's id is 40 bits wide, so on that path an
+/// oversized id was not a collision but a push that never appeared. A missing
+/// id fails the same way (awesome requires one), and gets the same derived id
+/// [contentFromData] gives it.
+///
+/// `content` comes back as a decoded map, which awesome accepts as readily as
+/// the JSON string. Anything without a usable `content` object is returned
+/// untouched: there is no id to fix, and awesome rejects it either way.
+Map jsonDataWithNotificationId(Map data) {
+ final content = switch (data['content']) {
+ final Map map => Map.from(map),
+ _ => _nestedContent(data),
+ };
+ if (content == null) return data;
+ String? text(Object? value) => switch (value) {
+ final String s => s,
+ final int i => i.toString(),
+ _ => null,
+ };
+ content['id'] = _resolveNotificationId(
+ flat: data['id'],
+ nested: content['id'],
+ channelKey: text(content['channelKey']) ?? text(data['channel']),
+ title: text(content['title']) ?? text(data['title']),
+ body: text(content['body']) ?? text(data['body']),
+ );
+ return {...data, 'content': content};
+}
+
+/// The notification id for a push, always one awesome accepts.
+///
+/// [flat] wins over [nested], matching every other field [contentFromData]
+/// reads. With neither, the id is derived from what the notification says, so
+/// the same alert sent twice still replaces itself rather than stacking.
+int _resolveNotificationId({
+ required Object? flat,
+ required Object? nested,
+ required String? channelKey,
+ required String? title,
+ required String? body,
+}) =>
+ _asNotificationId(flat) ??
+ _asNotificationId(nested) ??
+ _stableId('${channelKey ?? ''}\n${title ?? ''}\n${body ?? ''}');
+
+/// Maps a payload id into the positive signed 31-bit range awesome accepts.
///
/// awesome validates ids against the **signed 32-bit** range and throws —
/// killing the whole notification — on anything wider. The producer computes
/// its id as a 40-bit hex slice (`parseInt(md5slice, 16)`), so oversized ids
-/// are the common case, not the edge: they are treated as absent and the
-/// notification renders with id 0, replacing whatever came before it.
-int? _asNotificationId(Object? value) {
- final parsed = switch (value) {
- final int v => v,
- final String s => int.tryParse(s),
- _ => null,
- };
- if (parsed == null || parsed < -0x80000000 || parsed > 0x7FFFFFFF) {
- return null;
+/// are the common case, not the edge.
+///
+/// They used to be treated as absent and fall back to a fixed id, which made
+/// every such push share one slot in the shade: an EEW, a 震度速報 and a
+/// weather alert arriving together each replaced the one before, and only the
+/// last survived. Keeping the low 31 bits instead keeps what the id is *for* —
+/// one alert, one slot, the same slot when the same alert is resent — and the
+/// producer's ids are hash slices, so their low bits are as well spread as the
+/// whole. Positive, so a server alert can never land on the negative ids the
+/// test notifications reserve for themselves.
+///
+/// A non-numeric id is hashed rather than dropped, for the same reason.
+int? _asNotificationId(Object? value) => switch (value) {
+ final int v => v & 0x7FFFFFFF,
+ final String s when s.isEmpty => null,
+ final String s => switch (int.tryParse(s)) {
+ final int v => v & 0x7FFFFFFF,
+ null => _stableId(s),
+ },
+ _ => null,
+};
+
+/// A deterministic positive 31-bit id for [text] — 32-bit FNV-1a over its
+/// UTF-8 bytes, masked.
+///
+/// Not `String.hashCode`: nothing promises that is the same in the background
+/// isolate that draws a push and the next process that replaces it, and a
+/// notification id that moves between them is two notifications.
+int _stableId(String text) {
+ var hash = 0x811C9DC5;
+ for (final byte in utf8.encode(text)) {
+ hash = ((hash ^ byte) * 0x01000193) & 0xFFFFFFFF;
}
- return parsed;
+ return hash & 0x7FFFFFFF;
}
/// Fires when awesome accepts a notification, before it is shown.
@@ -787,16 +886,23 @@ Future onNotificationDisplayed(ReceivedNotification notification) async {
/// than a hand-built [NotificationContent]: at that point there is no engine
/// state to rely on, and the payload is already in awesome's own wire format
/// (the server sends a `content` object with `channelKey`), so handing it over
-/// whole is both shorter and closer to what the sender meant.
+/// whole is both shorter and closer to what the sender meant. Only its id is
+/// rewritten first — see [jsonDataWithNotificationId].
@pragma('vm:entry-point')
Future onFcmSilentData(FcmSilentData silentData) async {
final data = silentData.data;
if (data == null || data.isEmpty) return;
if (silentData.createdLifeCycle == NotificationLifeCycle.Terminated) {
- await AwesomeNotifications().createNotificationFromJsonData(
- data.cast(),
+ final created = await AwesomeNotifications().createNotificationFromJsonData(
+ jsonDataWithNotificationId(data.cast()),
);
+ // awesome reports every rejection on this path as a bare `false` — an
+ // invalid payload, an unknown channel, a refused permission — and with the
+ // app not running nothing else would ever say the push was lost.
+ if (!created) {
+ Log.warning('push: terminated-app notification was not created');
+ }
return;
}
diff --git a/lib/core/notifications/notification_settings_link.dart b/lib/core/notifications/notification_settings_link.dart
new file mode 100644
index 000000000..18ace1d73
--- /dev/null
+++ b/lib/core/notifications/notification_settings_link.dart
@@ -0,0 +1,88 @@
+/// The "DPIP Notification Settings" link in iOS Settings → DPIP →
+/// Notifications, and the request it sends back into the app.
+///
+/// iOS shows that row only for an app that asked for
+/// `UNAuthorizationOptions.providesAppNotificationSettings`. awesome's
+/// permission list has no entry for it, so `NotificationSettingsLinkPlugin`
+/// (ios/Runner) asks natively, and receives the tap through the
+/// notification-centre delegate. This side tells it when to ask and carries
+/// the tap to whoever routes it — `core` knows that settings were asked for,
+/// the app layer knows which page that is.
+///
+/// A no-op off iOS.
+library;
+
+import 'dart:async';
+import 'dart:io';
+
+import 'package:dpip/core/logging/log.dart';
+import 'package:flutter/services.dart';
+
+class NotificationSettingsLink {
+ NotificationSettingsLink({MethodChannel? channel, bool? isIOS})
+ : _channel =
+ channel ??
+ const MethodChannel('com.exptech.dpip/notification_settings_link'),
+ _isIOS = isIOS ?? Platform.isIOS;
+
+ final MethodChannel _channel;
+ final bool _isIOS;
+ void Function()? _onOpen;
+
+ /// Asks iOS for the Settings row, once notifications are allowed.
+ ///
+ /// Safe on every launch: the native side does nothing until the user has
+ /// allowed notifications, does nothing once the row exists, and never shows
+ /// a prompt — a decided app asking for an extra option is answered without
+ /// one. Best-effort: a failure only means the row is missing.
+ Future provide() async {
+ if (!_isIOS) return false;
+ try {
+ final provided = await _channel.invokeMethod('provide') ?? false;
+ Log.debug('notifications: settings link provided = $provided');
+ return provided;
+ } on PlatformException catch (error, stackTrace) {
+ Log.handle(error, stackTrace, 'notification settings link');
+ return false;
+ } on MissingPluginException {
+ return false;
+ }
+ }
+
+ /// Starts delivering Settings-row taps to [onOpen], including one that
+ /// arrived before this was called.
+ ///
+ /// Call once the router can navigate: a cold launch from the Settings row
+ /// records the tap before Dart is running, and this is when it is replayed.
+ Future listen(void Function() onOpen) async {
+ if (!_isIOS) return;
+ _onOpen = onOpen;
+ _channel.setMethodCallHandler((call) async {
+ if (call.method == 'open') await _drain();
+ });
+ await _drain();
+ }
+
+ /// Stops delivering taps.
+ void stop() {
+ _onOpen = null;
+ if (_isIOS) _channel.setMethodCallHandler(null);
+ }
+
+ /// Takes the native request flag, which reads and clears it in one step, so
+ /// a nudge and a startup check racing each other open the page once.
+ Future _drain() async {
+ final onOpen = _onOpen;
+ if (onOpen == null) return;
+ try {
+ final requested = await _channel.invokeMethod('take') ?? false;
+ if (!requested) return;
+ Log.info('notifications: opened from the system settings link');
+ onOpen();
+ } on PlatformException catch (error, stackTrace) {
+ Log.handle(error, stackTrace, 'notification settings link');
+ } on MissingPluginException {
+ // No native side (tests, an engine that never registered it).
+ }
+ }
+}
diff --git a/lib/core/notifications/retired_channels.dart b/lib/core/notifications/retired_channels.dart
new file mode 100644
index 000000000..aa6cfb4d5
--- /dev/null
+++ b/lib/core/notifications/retired_channels.dart
@@ -0,0 +1,153 @@
+/// Deletes the Android notification channels earlier builds created and this
+/// one no longer registers.
+///
+/// Android keeps a channel until the app that created it deletes it, through
+/// every upgrade. Each rename of the catalogue — `eew_alert` became
+/// `eew_alert-important`, which became `eew_alert-important-v2` — left the old
+/// channel behind on every device that had run the old build, so the system
+/// settings page listed two of every alert: one live, and one dead toggle that
+/// nothing posts to and that reads as the switch to turn the alert off.
+///
+/// Runs on every launch and is idempotent: one call lists what exists, and a
+/// second deletes only what is both retired and present, so a device that has
+/// already been swept costs a single platform call. A no-op off Android.
+library;
+
+import 'dart:io';
+
+import 'package:dpip/core/logging/log.dart';
+import 'package:dpip/core/notifications/notification_channels.dart';
+import 'package:flutter/services.dart';
+
+class RetiredChannels {
+ RetiredChannels({MethodChannel? channel, bool? isAndroid})
+ : _channel =
+ channel ??
+ const MethodChannel('com.exptech.dpip/plain_notification_channels'),
+ _isAndroid = isAndroid ?? Platform.isAndroid;
+
+ final MethodChannel _channel;
+ final bool _isAndroid;
+
+ /// Every channel key a shipped build has created and this one does not.
+ ///
+ /// Read off git history, not remembered: `lib/core/notify.dart` and
+ /// `lib/core/service.dart` before the rewrite, and the rewrite's own native
+ /// foreground service. **Append, never remove** — a key leaves this list
+ /// only when no device could still have it, which is never. When a key is
+ /// dropped from [NotificationChannels.channels] it belongs here.
+ static const Set keys = {
+ // The first catalogue (2024-06 to 2024-09).
+ 'eew_alert',
+ 'eew',
+ 'eew_silent',
+ 'eew_silence',
+ 'int_report',
+ 'int_report_silence',
+ 'eq',
+ 'report',
+ 'report_silence',
+ 'thunderstorm',
+ 'weather',
+ 'weather_major',
+ 'weather_minor',
+ 'evacuation_major',
+ 'evacuation_minor',
+ 'tsunami_warn',
+ 'tsunami',
+ 'tsunami_pacific_silent',
+ 'announcement',
+ 'heat',
+ 'flood',
+ 'rain_1',
+ 'rain_2',
+ 'typhoon',
+ 'volcano',
+ 'other',
+ 'basic_channel',
+ 'custom_sound',
+ // Renamed by importance (2024-09 to 2025-07), then given the `-v2` suffix
+ // the catalogue still carries.
+ 'eew_alert-important',
+ 'eew_alert-general',
+ 'eew_alert-silent',
+ 'eew-important',
+ 'eew-general',
+ 'eew-silence',
+ 'int_report-general',
+ 'int_report-silence',
+ 'report-general',
+ 'report-silence',
+ 'thunderstorm-general',
+ 'weather_major-important',
+ 'weather_minor-general',
+ 'evacuation_major-important',
+ 'evacuation_minor-general',
+ 'tsunami-important',
+ 'tsunami-general',
+ 'tsunami-silent',
+ 'announcement-general',
+ // Background-service notices, from before location went geofence-based.
+ 'foreground-general',
+ 'background-general',
+ 'my_foreground',
+ 'dpip_background_location',
+ };
+
+ /// awesome_notifications' other spelling of a channel id:
+ /// `_<32 hex digits>`, the MD5 of the channel model. It creates that
+ /// form whenever it updates a channel without forcing it, so a retired key
+ /// can survive under it as well as under the plain key.
+ static final RegExp _hashed = RegExp(r'^(.+)_[0-9a-f]{32}$');
+
+ /// The ids in [existing] that are retired channels — exactly those, and in
+ /// no case one the current catalogue registers.
+ ///
+ /// The live check is redundant with how [keys] is maintained, and kept
+ /// anyway: deleting a live alert channel would throw away the user's own
+ /// settings for it, and that must not depend on nobody ever mistyping this
+ /// list.
+ static List retiredAmong(Iterable existing) {
+ final live = {
+ for (final channel in NotificationChannels.channels) channel.channelKey,
+ };
+ return [
+ for (final id in existing)
+ if (!live.contains(id) && keys.contains(_keyOf(id))) id,
+ ];
+ }
+
+ static String _keyOf(String id) {
+ if (keys.contains(id)) return id;
+ return _hashed.firstMatch(id)?.group(1) ?? id;
+ }
+
+ /// Deletes every retired channel present on this device; returns how many
+ /// were deleted.
+ ///
+ /// Best-effort, like the rest of channel setup: a failure is logged and
+ /// leaves the dead toggles where they were, which is untidy, not harmful.
+ Future sweep() async {
+ if (!_isAndroid) return 0;
+ try {
+ final existing =
+ await _channel.invokeListMethod('list') ?? const [];
+ final retired = retiredAmong(existing);
+ if (retired.isEmpty) return 0;
+ final deleted =
+ await _channel.invokeMethod('delete', {'ids': retired}) ?? 0;
+ Log.info(
+ 'notifications: deleted $deleted retired channel(s) — '
+ '${retired.join(', ')}',
+ );
+ return deleted;
+ } on PlatformException catch (error, stackTrace) {
+ Log.handle(error, stackTrace, 'deleting retired notification channels');
+ return 0;
+ } on MissingPluginException {
+ // An engine that never registered the channel (tests, hot restarts into
+ // a fresh messenger) simply skips the sweep.
+ return 0;
+ }
+ }
+}
diff --git a/test/app/router/notification_routes_test.dart b/test/app/router/notification_routes_test.dart
index e936549b2..f5f211efe 100644
--- a/test/app/router/notification_routes_test.dart
+++ b/test/app/router/notification_routes_test.dart
@@ -453,4 +453,36 @@ void main() {
tsunamiMapLayerId,
});
});
+
+ group('the iOS Settings link', () {
+ test('opens the notification settings page over whatever is open', () {
+ final pushed = [];
+ routeNotificationSettingsLink(
+ onboardingComplete: true,
+ push: pushed.add,
+ currentRouteName: () => AppRoutes.map,
+ );
+ expect(pushed, [AppRoutes.notifySettings]);
+ });
+
+ test('does not stack a second copy of the page', () {
+ final pushed = [];
+ routeNotificationSettingsLink(
+ onboardingComplete: true,
+ push: pushed.add,
+ currentRouteName: () => AppRoutes.notifySettings,
+ );
+ expect(pushed, isEmpty);
+ });
+
+ test('waits for onboarding to finish', () {
+ final pushed = [];
+ routeNotificationSettingsLink(
+ onboardingComplete: false,
+ push: pushed.add,
+ currentRouteName: () => AppRoutes.onboarding,
+ );
+ expect(pushed, isEmpty);
+ });
+ });
}
diff --git a/test/core/notifications/content_from_data_test.dart b/test/core/notifications/content_from_data_test.dart
index 5c6b5612f..adcbb9360 100644
--- a/test/core/notifications/content_from_data_test.dart
+++ b/test/core/notifications/content_from_data_test.dart
@@ -56,11 +56,11 @@ void main() {
expect(content?.id, 305419896);
});
- test('an oversized id is dropped instead of killing the notification', () {
+ test('an oversized id keeps its low 31 bits instead of being dropped', () {
// The producer's id is `parseInt(md5.slice(0, 10), 16)` — up to 40 bits,
- // routinely wider than the signed 32-bit range awesome validates. Before
- // the clamp this threw out of createNotification and the foreground push
- // simply never rendered.
+ // routinely wider than the signed 32-bit range awesome validates. Treating
+ // that as "no id" gave every such push the same fallback slot, so three
+ // different alerts arriving together left only the last one in the shade.
final content = _content(
{
'content':
@@ -70,13 +70,62 @@ void main() {
body: '花蓮縣近海',
);
- expect(content?.id, 0, reason: 'out of 32-bit range → treated as absent');
+ expect(content?.id, 0xF12345678 & 0x7FFFFFFF);
expect(
content?.channelKey,
'eq-v2',
reason: 'the rest of the payload still applies',
);
expect(content?.title, '地震速報');
+ // The tap still carries what the producer sent, not the folded id.
+ expect(content?.payload?['id'], '${0xF12345678}');
+ });
+
+ test('different oversized alerts get different shade slots', () {
+ // The three ids a real burst produced — an EEW, a 震度速報 and a weather
+ // alert — every one of them outside 32 bits.
+ const ids = [0xA1B2C3D4E5, 0x0F00BA9876, 0x9C8D7E6F50];
+ final resolved = {
+ for (final id in ids) _content({'id': '$id'}, title: 'T', body: 'B')!.id,
+ };
+
+ expect(resolved, hasLength(ids.length));
+ expect(resolved, everyElement(inInclusiveRange(1, 0x7FFFFFFF)));
+ });
+
+ test('the same alert sent twice resolves to the same slot', () {
+ final first = _content({'id': '${0xA1B2C3D4E5}'}, title: 'T', body: 'B');
+ final again = _content({'id': '${0xA1B2C3D4E5}'}, title: 'T', body: 'B');
+
+ expect(first?.id, again?.id);
+ });
+
+ test('a missing or non-numeric id is derived, never a shared default', () {
+ final eew = _content(
+ {'channel': 'eew-important-v2'},
+ title: '速報',
+ body: 'A',
+ );
+ final report = _content(
+ {'channel': 'int_report-general-v2'},
+ title: '震度',
+ body: 'B',
+ );
+ final named = _content({'id': 'eq-2026-1016'}, title: 'T', body: 'B');
+
+ expect(eew?.id, isNot(report?.id));
+ expect(eew?.id, isPositive);
+ expect(report?.id, isPositive);
+ expect(named?.id, isPositive);
+ // Derived from the text, so the same alert resent replaces itself.
+ expect(
+ _content({'channel': 'eew-important-v2'}, title: '速報', body: 'A')?.id,
+ eew?.id,
+ );
+ expect(
+ _content({'id': 'eq-2026-1016'}, title: 'X', body: 'Y')?.id,
+ named?.id,
+ );
});
test('flat keys win where both shapes exist', () {
@@ -124,7 +173,9 @@ void main() {
});
expect(content?.title, '7');
expect(content?.body, '8');
- expect(content?.id, -5);
+ // Folded to positive: negative ids belong to test notifications, and a
+ // server alert must never be able to replace one or be replaced by it.
+ expect(content?.id, -5 & 0x7FFFFFFF);
expect(content?.payload?['id'], '-5');
});
@@ -143,20 +194,78 @@ void main() {
);
});
- test('an id outside the signed 32-bit range is dropped', () {
- expect(
- _content({'id': '2147483647'}, title: 'T', body: 'B')?.id,
- 2147483647,
- );
- expect(
- _content({'id': '-2147483648'}, title: 'T', body: 'B')?.id,
- -2147483648,
- );
- expect(_content({'id': '2147483648'}, title: 'T', body: 'B')?.id, 0);
- expect(_content({'id': 'nope'}, title: 'T', body: 'B')?.id, 0);
+ test('every id lands in the positive 31-bit range', () {
+ int? id(Object value) => _content({'id': value}, title: 'T', body: 'B')?.id;
+
+ expect(id('2147483647'), 2147483647);
+ expect(id('42'), 42);
+ expect(id('2147483649'), 1);
+ expect(id('-1'), 0x7FFFFFFF);
+ expect(id('${0xFFFFFFFFFF}'), 0x7FFFFFFF);
+ expect(id('nope'), isPositive);
+ // Wider than 64 bits: not an int at all, so it is hashed like any text.
+ expect(id('123456789012345678901234567890'), isPositive);
expect(
- _content({'content': '{"title":"T","body":"B","id":2147483648}'})?.id,
- 0,
+ _content({'content': '{"title":"T","body":"B","id":2147483649}'})?.id,
+ 1,
);
});
+
+ group('jsonDataWithNotificationId', () {
+ test('folds an oversized content id the terminated path would reject', () {
+ final data = jsonDataWithNotificationId({
+ 'content':
+ '{"id": ${0xF12345678}, "channelKey": "eq-v2", "body": "測試"}',
+ });
+
+ final content = data['content'] as Map;
+ expect(content['id'], 0xF12345678 & 0x7FFFFFFF);
+ expect(content['channelKey'], 'eq-v2');
+ expect(content['body'], '測試');
+ });
+
+ test('gives a content with no id one derived from what it says', () {
+ Map content(String body) =>
+ jsonDataWithNotificationId({
+ 'content': '{"channelKey": "eq-v2", "body": "$body"}',
+ })['content']
+ as Map;
+
+ expect(content('A')['id'], isPositive);
+ expect(content('A')['id'], content('A')['id']);
+ expect(content('A')['id'], isNot(content('B')['id']));
+ });
+
+ test('a flat id wins, as it does for every other field', () {
+ final data = jsonDataWithNotificationId({
+ 'id': '7',
+ 'content': '{"id": 9, "channelKey": "eq-v2"}',
+ });
+
+ expect((data['content'] as Map)['id'], 7);
+ expect(data['id'], '7');
+ });
+
+ test('accepts content that already arrived decoded', () {
+ final data = jsonDataWithNotificationId({
+ 'content': {'id': 0xF12345678, 'channelKey': 'eq-v2'},
+ });
+
+ expect(
+ (data['content'] as Map)['id'],
+ 0xF12345678 & 0x7FFFFFFF,
+ );
+ });
+
+ test('leaves a payload with no usable content alone', () {
+ for (final data in >[
+ {},
+ {'content': '{not json'},
+ {'content': '[1, 2]'},
+ {'channel': 'eq-v2', 'title': 'T'},
+ ]) {
+ expect(jsonDataWithNotificationId(data), same(data));
+ }
+ });
+ });
}
diff --git a/test/core/notifications/fcm_default_channel_test.dart b/test/core/notifications/fcm_default_channel_test.dart
new file mode 100644
index 000000000..2fbda6205
--- /dev/null
+++ b/test/core/notifications/fcm_default_channel_test.dart
@@ -0,0 +1,45 @@
+/// The Android manifest's FCM default channel must name a channel this app
+/// actually creates.
+///
+/// The value is a bare string in XML, read by Google Play services when it
+/// draws a push whose own channel id is unknown. Nothing compiles it against
+/// the catalogue, so a rename in `notification_channels.dart` would leave it
+/// pointing at a channel that no longer exists — and FCM then falls back to
+/// its own "Miscellaneous" channel, which is the exact outcome the entry is
+/// there to prevent. Silent on a device; loud here.
+library;
+
+import 'dart:io';
+
+import 'package:dpip/core/notifications/notification_channels.dart';
+import 'package:flutter_test/flutter_test.dart';
+
+const _manifest = 'android/app/src/main/AndroidManifest.xml';
+const _key = 'com.google.firebase.messaging.default_notification_channel_id';
+
+void main() {
+ String? defaultChannel() {
+ final manifest = File(_manifest).readAsStringSync();
+ final match = RegExp(
+ r' ',
+ ).firstMatch(manifest);
+ return match?.group(1);
+ }
+
+ test('the manifest declares an FCM default notification channel', () {
+ expect(defaultChannel(), isNotNull, reason: '$_key is missing');
+ });
+
+ test('the default is the general announcement channel', () {
+ expect(defaultChannel(), 'announcement-general-v2');
+ });
+
+ test('the default is a channel the catalogue registers', () {
+ final keys = [
+ for (final channel in NotificationChannels.channels) channel.channelKey,
+ ];
+ expect(keys, contains(defaultChannel()));
+ });
+}
diff --git a/test/core/notifications/ios_entitlements_test.dart b/test/core/notifications/ios_entitlements_test.dart
new file mode 100644
index 000000000..e41d7bec1
--- /dev/null
+++ b/test/core/notifications/ios_entitlements_test.dart
@@ -0,0 +1,66 @@
+/// The iOS notification entitlements, in both signing configurations.
+///
+/// Debug signs with `Runner.entitlements` and Release/Profile with
+/// `RunnerRelease.entitlements`. They are split on purpose (`aps-environment`
+/// differs), which is exactly what lets a key reach one and not the other: a
+/// TestFlight build missing the time-sensitive key still installs, still
+/// receives every push, and has every High/Max alert quietly held by Focus.
+/// Nothing on a device says so.
+library;
+
+import 'dart:io';
+
+import 'package:flutter_test/flutter_test.dart';
+
+const _files = [
+ 'ios/Runner/Runner.entitlements',
+ 'ios/Runner/RunnerRelease.entitlements',
+];
+
+/// Whether [plist] sets [key] to ` `, comments aside.
+bool _enabled(String plist, String key) {
+ final withoutComments = plist.replaceAll(
+ RegExp(r'', dotAll: true),
+ '',
+ );
+ return RegExp('${RegExp.escape(key)} \\s* ')
+ .hasMatch(withoutComments);
+}
+
+void main() {
+ for (final path in _files) {
+ group(path, () {
+ final plist = File(path).readAsStringSync();
+
+ test('allows critical alerts', () {
+ expect(
+ _enabled(
+ plist,
+ 'com.apple.developer.usernotifications.critical-alerts',
+ ),
+ isTrue,
+ );
+ });
+
+ test('allows time-sensitive notifications', () {
+ expect(
+ _enabled(
+ plist,
+ 'com.apple.developer.usernotifications.time-sensitive',
+ ),
+ isTrue,
+ );
+ });
+ });
+ }
+
+ test('a commented-out key does not count', () {
+ const plist =
+ '';
+ expect(
+ _enabled(plist, 'com.apple.developer.usernotifications.time-sensitive'),
+ isFalse,
+ );
+ });
+}
diff --git a/test/core/notifications/notification_service_test.dart b/test/core/notifications/notification_service_test.dart
index 8a3a9bdf9..fa870cf37 100644
--- a/test/core/notifications/notification_service_test.dart
+++ b/test/core/notifications/notification_service_test.dart
@@ -16,6 +16,7 @@ import 'package:awesome_notifications/awesome_notifications_platform_interface.d
import 'package:awesome_notifications_fcm/awesome_notifications_fcm.dart';
import 'package:dpip/core/notifications/notification_channels.dart';
import 'package:dpip/core/notifications/notification_service.dart';
+import 'package:dpip/core/notifications/notification_settings_link.dart';
import 'package:dpip/core/permissions/permission_outcome.dart';
import 'package:dpip/core/settings/setting_keys.dart';
import 'package:dpip/core/settings/settings_store.dart';
@@ -42,6 +43,7 @@ void main() {
var createOk = true;
var createThrows = false;
var created = 0;
+ Map? lastCreated;
var initializeFailures = 0;
var removeFailures = 0;
var setChannelFailures = 0;
@@ -59,6 +61,7 @@ void main() {
createOk = true;
createThrows = false;
created = 0;
+ lastCreated = null;
initializeFailures = 0;
removeFailures = 0;
setChannelFailures = 0;
@@ -92,6 +95,7 @@ void main() {
case 'createNewNotification':
if (createThrows) throw PlatformException(code: 'create');
created++;
+ lastCreated = call.arguments as Map?;
return createOk;
case 'initialize':
initializeCalls++;
@@ -175,6 +179,25 @@ void main() {
expect(created, greaterThanOrEqualTo(2));
});
+ test('a terminated-app push with a 40-bit id is still created', () async {
+ // The producer's id is a 40-bit hash slice. Handed to awesome as-is, its
+ // own range check throws inside createNotificationFromJsonData, which
+ // swallows that into `false` — the push simply never appeared.
+ await onFcmSilentData(
+ silent({
+ 'createdLifeCycle': 'Terminated',
+ 'content':
+ '{"id": ${0xF12345678}, "channelKey": "eq-v2", '
+ '"title": "地震", "body": "花蓮"}',
+ }),
+ );
+
+ expect(created, 1);
+ final content = lastCreated?['content'] as Map?;
+ expect(content?['id'], 0xF12345678 & 0x7FFFFFFF);
+ expect(content?['channelKey'], 'eq-v2');
+ });
+
test('a foreground EEW waits for the announcement, then plays', () async {
final store = SettingsStore.inMemory();
final service = NotificationService(store);
@@ -291,6 +314,38 @@ void main() {
await onNotificationDisplayed(createdNote);
});
+ test('a grant asks iOS for the Settings link, a refusal does not', () async {
+ const linkChannel = MethodChannel('test/settings_link');
+ var provides = 0;
+ messenger.setMockMethodCallHandler(linkChannel, (call) async {
+ if (call.method == 'provide') provides++;
+ return true;
+ });
+ addTearDown(() => messenger.setMockMethodCallHandler(linkChannel, null));
+ final service = NotificationService(
+ SettingsStore.inMemory(),
+ settingsLink: NotificationSettingsLink(channel: linkChannel, isIOS: true),
+ );
+ addTearDown(service.foregroundEewGate.dispose);
+
+ alertStatus = 'notDetermined';
+ requestResult = false;
+ expect(await service.requestPermission(), PermissionOutcome.needsSettings);
+ await _settle();
+ expect(provides, 0);
+
+ requestResult = true;
+ expect(await service.requestPermission(), PermissionOutcome.granted);
+ await _settle();
+ expect(provides, 1);
+
+ // Already allowed — an install from before the link existed.
+ allowed = true;
+ expect(await service.requestPermission(), PermissionOutcome.granted);
+ await _settle();
+ expect(provides, 2);
+ });
+
testWidgets('a prompt that never answers falls back to settings', (
tester,
) async {
diff --git a/test/core/notifications/notification_settings_link_test.dart b/test/core/notifications/notification_settings_link_test.dart
new file mode 100644
index 000000000..f09ab8882
--- /dev/null
+++ b/test/core/notifications/notification_settings_link_test.dart
@@ -0,0 +1,133 @@
+/// The iOS Settings → DPIP → Notifications link: asking for it, and carrying
+/// its tap into the app exactly once.
+///
+/// The native half (`NotificationSettingsLinkPlugin.swift`) holds the request
+/// as a flag that `take` reads and clears; these tests stand in for it, so
+/// what they pin is the Dart contract — a tap that arrived before Dart was
+/// listening is replayed, a nudge with nothing pending opens nothing, and the
+/// whole thing is inert off iOS.
+library;
+
+import 'package:dpip/core/notifications/notification_settings_link.dart';
+import 'package:flutter/services.dart';
+import 'package:flutter_test/flutter_test.dart';
+
+void main() {
+ TestWidgetsFlutterBinding.ensureInitialized();
+
+ const channel = MethodChannel('test/notification_settings_link');
+ final messenger =
+ TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger;
+
+ var pending = false;
+ var provided = true;
+ final calls = [];
+
+ setUp(() {
+ pending = false;
+ provided = true;
+ calls.clear();
+ messenger.setMockMethodCallHandler(channel, (call) async {
+ calls.add(call.method);
+ switch (call.method) {
+ case 'take':
+ final was = pending;
+ pending = false;
+ return was;
+ case 'provide':
+ return provided;
+ }
+ return null;
+ });
+ });
+ tearDown(() => messenger.setMockMethodCallHandler(channel, null));
+
+ NotificationSettingsLink link({bool isIOS = true}) =>
+ NotificationSettingsLink(channel: channel, isIOS: isIOS);
+
+ /// What the native side does when iOS calls `openSettingsFor`.
+ Future nudge() => messenger.handlePlatformMessage(
+ channel.name,
+ const StandardMethodCodec().encodeMethodCall(const MethodCall('open')),
+ (_) {},
+ );
+
+ test(
+ 'a tap that launched the app is replayed when listening starts',
+ () async {
+ pending = true;
+ var opened = 0;
+
+ await link().listen(() => opened++);
+
+ expect(opened, 1);
+ expect(pending, isFalse);
+ },
+ );
+
+ test('a tap while running opens the page once', () async {
+ var opened = 0;
+ final subject = link();
+ await subject.listen(() => opened++);
+ expect(opened, 0);
+
+ pending = true;
+ await nudge();
+ expect(opened, 1);
+
+ // A nudge with nothing pending — the flag already taken — opens nothing.
+ await nudge();
+ expect(opened, 1);
+ subject.stop();
+ });
+
+ test('stop ends delivery', () async {
+ var opened = 0;
+ final subject = link();
+ await subject.listen(() => opened++);
+ subject.stop();
+
+ pending = true;
+ await nudge();
+
+ expect(opened, 0);
+ expect(pending, isTrue, reason: 'left for whoever listens next');
+ });
+
+ test('provide reports what the native side answered', () async {
+ expect(await link().provide(), isTrue);
+ provided = false;
+ expect(await link().provide(), isFalse);
+ });
+
+ test('a native failure is swallowed', () async {
+ messenger.setMockMethodCallHandler(channel, (call) async {
+ throw PlatformException(code: 'boom');
+ });
+
+ expect(await link().provide(), isFalse);
+ var opened = 0;
+ await link().listen(() => opened++);
+ expect(opened, 0);
+ });
+
+ test('no native side at all is skipped', () async {
+ messenger.setMockMethodCallHandler(channel, null);
+
+ expect(await link().provide(), isFalse);
+ await expectLater(link().listen(() {}), completes);
+ });
+
+ test('never reaches the platform off iOS', () async {
+ pending = true;
+ var opened = 0;
+ final subject = link(isIOS: false);
+
+ expect(await subject.provide(), isFalse);
+ await subject.listen(() => opened++);
+ subject.stop();
+
+ expect(opened, 0);
+ expect(calls, isEmpty);
+ });
+}
diff --git a/test/core/notifications/retired_channels_test.dart b/test/core/notifications/retired_channels_test.dart
new file mode 100644
index 000000000..c4f21f980
--- /dev/null
+++ b/test/core/notifications/retired_channels_test.dart
@@ -0,0 +1,163 @@
+/// Retired channel ids, and the sweep that deletes them on Android.
+///
+/// Two ways this goes wrong, both silent on a device: a live alert channel
+/// treated as retired — deleted on every launch, taking the user's own
+/// settings for it — or a retired one missed, left in system settings as a
+/// dead duplicate toggle. The list is pinned against the catalogue for the
+/// first and spelled out in full for the second.
+library;
+
+import 'package:dpip/core/notifications/notification_channels.dart';
+import 'package:dpip/core/notifications/retired_channels.dart';
+import 'package:flutter/services.dart';
+import 'package:flutter_test/flutter_test.dart';
+
+void main() {
+ TestWidgetsFlutterBinding.ensureInitialized();
+
+ const channel = MethodChannel('test/retired_channels');
+ final messenger =
+ TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger;
+ tearDown(() => messenger.setMockMethodCallHandler(channel, null));
+
+ final live = {
+ for (final channel in NotificationChannels.channels) channel.channelKey!,
+ };
+
+ test('no retired key is a channel the catalogue still registers', () {
+ expect(RetiredChannels.keys.intersection(live), isEmpty);
+ });
+
+ test('every alert channel ever renamed is retired', () {
+ // Each live `-v2` alert key, minus the suffix, is a channel a shipped
+ // build created under that name. `thunderstorm-important` is the one
+ // exception: it was introduced already carrying the suffix.
+ final previous = {
+ for (final key in live)
+ if (key.endsWith('-v2')) key.substring(0, key.length - 3),
+ }..remove('thunderstorm-important');
+
+ expect(RetiredChannels.keys, containsAll(previous));
+ expect(RetiredChannels.keys, containsAll(['eew_alert-important', 'eq']));
+ });
+
+ test('the first catalogue and the service channels are retired too', () {
+ expect(
+ RetiredChannels.keys,
+ containsAll([
+ 'eew_alert',
+ 'eew',
+ 'eew_silence',
+ 'int_report_silence',
+ 'tsunami_warn',
+ 'tsunami_pacific_silent',
+ 'announcement',
+ 'my_foreground',
+ 'dpip_background_location',
+ ]),
+ );
+ });
+
+ group('retiredAmong', () {
+ test('picks the retired ids and leaves every live one', () {
+ final existing = [...live, 'eew_alert-important', 'eq', 'miscellaneous'];
+
+ expect(RetiredChannels.retiredAmong(existing), [
+ 'eew_alert-important',
+ 'eq',
+ ]);
+ });
+
+ test('catches the hashed spelling awesome also creates', () {
+ const digest = '0123456789abcdef0123456789abcdef';
+
+ expect(
+ RetiredChannels.retiredAmong([
+ 'eew_alert-important_$digest',
+ 'eew_alert-important-v2_$digest',
+ 'not-ours_$digest',
+ 'eq_tooshort',
+ ]),
+ ['eew_alert-important_$digest'],
+ );
+ });
+
+ test('never names a live channel, whatever the list says', () {
+ expect(RetiredChannels.retiredAmong(live), isEmpty);
+ });
+ });
+
+ group('sweep', () {
+ test('lists, then deletes only the retired ids present', () async {
+ final calls = [];
+ messenger.setMockMethodCallHandler(channel, (call) async {
+ calls.add(call);
+ return switch (call.method) {
+ 'list' => ['eew_alert-important-v2', 'eew_alert-important', 'eq'],
+ 'delete' => (call.arguments as Map)['ids'].length,
+ _ => null,
+ };
+ });
+
+ final deleted = await RetiredChannels(
+ channel: channel,
+ isAndroid: true,
+ ).sweep();
+
+ expect(deleted, 2);
+ expect(calls.map((c) => c.method), ['list', 'delete']);
+ expect((calls.last.arguments as Map)['ids'], [
+ 'eew_alert-important',
+ 'eq',
+ ]);
+ });
+
+ test('a swept device costs one call and deletes nothing', () async {
+ final calls = [];
+ messenger.setMockMethodCallHandler(channel, (call) async {
+ calls.add(call.method);
+ return call.method == 'list' ? live.toList() : 0;
+ });
+
+ final deleted = await RetiredChannels(
+ channel: channel,
+ isAndroid: true,
+ ).sweep();
+
+ expect(deleted, 0);
+ expect(calls, ['list']);
+ });
+
+ test('never reaches the platform off Android', () async {
+ var invoked = false;
+ messenger.setMockMethodCallHandler(channel, (call) async {
+ invoked = true;
+ return null;
+ });
+
+ expect(
+ await RetiredChannels(channel: channel, isAndroid: false).sweep(),
+ 0,
+ );
+ expect(invoked, isFalse);
+ });
+
+ test('a platform failure is swallowed, not thrown into init', () async {
+ messenger.setMockMethodCallHandler(channel, (call) async {
+ throw PlatformException(code: 'boom');
+ });
+
+ expect(
+ await RetiredChannels(channel: channel, isAndroid: true).sweep(),
+ 0,
+ );
+ });
+
+ test('a missing native handler is skipped', () async {
+ expect(
+ await RetiredChannels(channel: channel, isAndroid: true).sweep(),
+ 0,
+ );
+ });
+ });
+}
diff --git a/test/features/onboarding/presentation/widgets/onboarding_permissions_test.dart b/test/features/onboarding/presentation/widgets/onboarding_permissions_test.dart
index a2976aa83..396649379 100644
--- a/test/features/onboarding/presentation/widgets/onboarding_permissions_test.dart
+++ b/test/features/onboarding/presentation/widgets/onboarding_permissions_test.dart
@@ -30,6 +30,7 @@ import 'package:dpip/core/geo/location_status.dart';
import 'package:dpip/core/geo/town.dart';
import 'package:dpip/core/notifications/foreground_eew_announcement_gate.dart';
import 'package:dpip/core/notifications/notification_service.dart';
+import 'package:dpip/core/notifications/notification_settings_link.dart';
import 'package:dpip/core/permissions/permission_health.dart';
import 'package:dpip/core/permissions/permission_outcome.dart';
import 'package:dpip/features/onboarding/presentation/widgets/onboarding_permissions.dart';
@@ -87,6 +88,9 @@ class _FakeNotificationService implements NotificationService {
@override
ForegroundEewAnnouncementGate get foregroundEewGate =>
throw UnimplementedError();
+
+ @override
+ NotificationSettingsLink get settingsLink => throw UnimplementedError();
}
/// As above: every [LocationService] member is stubbed explicitly.