From bb8fa5203e30e6e40aff788896f6139b3394f6ca Mon Sep 17 00:00:00 2001 From: YuYu1015 Date: Fri, 16 Oct 2026 06:42:21 +0800 Subject: [PATCH 1/5] fix(notify): keep alerts with oversized ids in their own shade slot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Platform: android Fix(zh-Hant): 修正同時收到多則警報時,前一則會被下一則蓋掉,通知欄只剩最後一則 Fix(en-US): fix alerts arriving together replacing one another so only the last stayed in the notification shade --- .../notifications/notification_service.dart | 119 ++++++++++++-- .../notifications/content_from_data_test.dart | 147 +++++++++++++++--- .../notification_service_test.dart | 22 +++ 3 files changed, 252 insertions(+), 36 deletions(-) diff --git a/lib/core/notifications/notification_service.dart b/lib/core/notifications/notification_service.dart index 26cb2c29a..4eebc8e35 100644 --- a/lib/core/notifications/notification_service.dart +++ b/lib/core/notifications/notification_service.dart @@ -689,8 +689,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 +727,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 +865,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/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/notification_service_test.dart b/test/core/notifications/notification_service_test.dart index 8a3a9bdf9..6b06e62ca 100644 --- a/test/core/notifications/notification_service_test.dart +++ b/test/core/notifications/notification_service_test.dart @@ -42,6 +42,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 +60,7 @@ void main() { createOk = true; createThrows = false; created = 0; + lastCreated = null; initializeFailures = 0; removeFailures = 0; setChannelFailures = 0; @@ -92,6 +94,7 @@ void main() { case 'createNewNotification': if (createThrows) throw PlatformException(code: 'create'); created++; + lastCreated = call.arguments as Map?; return createOk; case 'initialize': initializeCalls++; @@ -175,6 +178,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); From 11e03e0aa6bf86d7bf09e2c4fcd1161b73c647be Mon Sep 17 00:00:00 2001 From: YuYu1015 Date: Fri, 16 Oct 2026 06:43:43 +0800 Subject: [PATCH 2/5] fix(notify): send unrecognised pushes to the announcement channel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Platform: android Fix(zh-Hant): 修正系統代為顯示的推播指定了不認得的類別時,會落到「其他」並使用系統預設音效 Fix(en-US): fix pushes the system shows for an unknown category landing in "Miscellaneous" with the default sound --- android/app/src/main/AndroidManifest.xml | 15 +++++++ .../fcm_default_channel_test.dart | 45 +++++++++++++++++++ 2 files changed, 60 insertions(+) create mode 100644 test/core/notifications/fcm_default_channel_test.dart 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/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())); + }); +} From 6f02113141e9171e4505b0e7bf6bda3d0e80b96e Mon Sep 17 00:00:00 2001 From: YuYu1015 Date: Fri, 16 Oct 2026 06:49:18 +0800 Subject: [PATCH 3/5] fix(notify): delete notification categories older versions left behind MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Platform: android Fix(zh-Hant): 修正升級後系統通知設定裡留著舊版的重複通知類別,關掉它們其實沒有任何作用 Fix(en-US): fix upgraded phones listing leftover duplicate notification categories in system settings that no longer control anything --- .../com/exptech/dpip/PlainChannelsChannel.kt | 37 +++- .../notifications/notification_channels.dart | 4 + .../notifications/notification_service.dart | 3 + lib/core/notifications/retired_channels.dart | 153 ++++++++++++++++ .../notifications/retired_channels_test.dart | 163 ++++++++++++++++++ 5 files changed, 358 insertions(+), 2 deletions(-) create mode 100644 lib/core/notifications/retired_channels.dart create mode 100644 test/core/notifications/retired_channels_test.dart 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/lib/core/notifications/notification_channels.dart b/lib/core/notifications/notification_channels.dart index dca214b8e..c5d78c8be 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._(); diff --git a/lib/core/notifications/notification_service.dart b/lib/core/notifications/notification_service.dart index 4eebc8e35..dce529d47 100644 --- a/lib/core/notifications/notification_service.dart +++ b/lib/core/notifications/notification_service.dart @@ -12,6 +12,7 @@ import 'package:dpip/core/notifications/foreground_eew_announcement_gate.dart'; import 'package:dpip/core/notifications/notification_samples.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'; @@ -438,6 +439,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); } 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/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, + ); + }); + }); +} From 2468b353751d41e9db688c50aaf54e655e351e56 Mon Sep 17 00:00:00 2001 From: YuYu1015 Date: Fri, 16 Oct 2026 06:50:40 +0800 Subject: [PATCH 4/5] fix(notify): let urgent alerts through Focus on iOS MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Platform: ios Fix(zh-Hant): 修正 iOS 開啟專注模式或睡眠模式時,地震速報等即時警報會被擋下、等解除後才出現 Fix(en-US): fix time-sensitive alerts such as earthquake warnings being held back by Focus or Sleep on iOS --- ios/Runner/Runner.entitlements | 2 + ios/Runner/RunnerRelease.entitlements | 9 +++ .../notifications/notification_channels.dart | 7 +- .../notifications/ios_entitlements_test.dart | 66 +++++++++++++++++++ 4 files changed, 82 insertions(+), 2 deletions(-) create mode 100644 test/core/notifications/ios_entitlements_test.dart 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/core/notifications/notification_channels.dart b/lib/core/notifications/notification_channels.dart index c5d78c8be..75ca38ea0 100644 --- a/lib/core/notifications/notification_channels.dart +++ b/lib/core/notifications/notification_channels.dart @@ -470,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/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, + ); + }); +} From 676cff1420c09bc56711f985d001b4dfa37b27b3 Mon Sep 17 00:00:00 2001 From: YuYu1015 Date: Fri, 16 Oct 2026 07:02:56 +0800 Subject: [PATCH 5/5] feat(notify): link iOS notification settings back into the app MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Platform: ios New(zh-Hant): iOS「設定」裡 DPIP 的通知頁面多了一個連結,點了會直接打開 app 內的通知設定 New(en-US): iOS Settings now links from DPIP's notification page straight to the app's own notification settings --- ios/Runner.xcodeproj/project.pbxproj | 4 + ios/Runner/AppDelegate.swift | 13 ++ .../NotificationSettingsLinkPlugin.swift | 95 +++++++++++++ lib/app/app.dart | 10 ++ lib/app/router/notification_routes.dart | 32 +++++ .../notifications/notification_service.dart | 24 +++- .../notification_settings_link.dart | 88 ++++++++++++ test/app/router/notification_routes_test.dart | 32 +++++ .../notification_service_test.dart | 33 +++++ .../notification_settings_link_test.dart | 133 ++++++++++++++++++ .../widgets/onboarding_permissions_test.dart | 4 + 11 files changed, 465 insertions(+), 3 deletions(-) create mode 100644 ios/Runner/NotificationSettingsLinkPlugin.swift create mode 100644 lib/core/notifications/notification_settings_link.dart create mode 100644 test/core/notifications/notification_settings_link_test.dart 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/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_service.dart b/lib/core/notifications/notification_service.dart index dce529d47..d3b72774b 100644 --- a/lib/core/notifications/notification_service.dart +++ b/lib/core/notifications/notification_service.dart @@ -10,6 +10,7 @@ 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'; @@ -39,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; @@ -119,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. @@ -164,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. // @@ -203,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 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/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/notification_service_test.dart b/test/core/notifications/notification_service_test.dart index 6b06e62ca..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'; @@ -313,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/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.