Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions lib/app/router/app_router.dart
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import 'package:dpip/features/data/presentation/pages/tonight_page.dart';
import 'package:dpip/features/data/presentation/pages/sun_page.dart';
import 'package:dpip/features/meshtastic/presentation/pages/meshtastic_page.dart';
import 'package:dpip/features/more/presentation/pages/more_page.dart';
import 'package:dpip/features/notification/presentation/pages/notification_test_page.dart';
import 'package:dpip/features/notification/presentation/pages/notify_page.dart';
import 'package:dpip/features/onboarding/presentation/pages/onboarding_page.dart';
import 'package:dpip/features/settings/presentation/pages/developer_page.dart';
Expand All @@ -34,6 +35,8 @@ import 'package:dpip/features/settings/presentation/pages/eew_source_page.dart';
import 'package:dpip/features/settings/presentation/pages/language_page.dart';
import 'package:dpip/features/settings/presentation/pages/permissions_page.dart';
import 'package:dpip/features/sponsor/presentation/pages/sponsor_page.dart';
import 'package:dpip/features/bug_tracker/presentation/pages/bug_list_page.dart';
import 'package:dpip/features/bug_tracker/presentation/pages/bug_thread_page.dart';
import 'package:dpip/features/status/presentation/pages/server_status_page.dart';
import 'package:dpip/features/weather/presentation/pages/weather_ranking_page.dart';
import 'package:dpip/shared/navigation/app_routes.dart';
Expand Down Expand Up @@ -270,6 +273,13 @@ final GoRouter appRouter = GoRouter(
path: AppRoutes.notifySettingsPath,
name: AppRoutes.notifySettings,
builder: (_, _) => const NotifyPage(),
routes: [
GoRoute(
path: AppRoutes.notifyTestPath,
name: AppRoutes.notifyTest,
builder: (_, _) => const NotificationTestPage(),
),
],
),
GoRoute(
path: AppRoutes.sponsorPath,
Expand All @@ -281,6 +291,19 @@ final GoRouter appRouter = GoRouter(
name: AppRoutes.serverStatus,
builder: (_, _) => const ServerStatusPage(),
),
GoRoute(
path: AppRoutes.bugTrackerPath,
name: AppRoutes.bugTracker,
builder: (_, _) => const BugListPage(),
routes: [
GoRoute(
path: AppRoutes.bugThreadPath,
name: AppRoutes.bugThread,
builder: (_, state) =>
BugThreadPage(id: int.tryParse(state.pathParameters['id']!) ?? 0),
),
],
),
],
);

Expand Down
2 changes: 2 additions & 0 deletions lib/bootstrap.dart
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ import 'package:dpip/features/home/home_providers.dart';
import 'package:dpip/features/meshtastic/meshtastic_providers.dart';
import 'package:dpip/features/notification/notification_providers.dart';
import 'package:dpip/features/sponsor/sponsor_providers.dart';
import 'package:dpip/features/bug_tracker/bug_tracker_providers.dart';
import 'package:dpip/features/status/status_providers.dart';
import 'package:dpip/features/typhoon/typhoon_providers.dart';
import 'package:dpip/features/weather/weather_providers.dart';
Expand Down Expand Up @@ -423,6 +424,7 @@ Future<void> bootstrap() async {
...sponsorProviders(),
...homeProviders(),
...statusProviders(deps),
...bugTrackerProviders(deps),
],
),
);
Expand Down
7 changes: 6 additions & 1 deletion lib/core/network/api_exception.dart
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import 'package:dio/dio.dart';
import 'package:dpip/core/error/failure.dart';
import 'package:dpip/core/error/result.dart';
import 'package:dpip/core/logging/log.dart';

/// Runs [body] and folds it into a [Result]: its value on success, a typed
/// [Failure] (via [mapException]) on any throw.
Expand All @@ -12,7 +13,11 @@ import 'package:dpip/core/error/result.dart';
Future<Result<T>> guardResult<T>(Future<T> Function() body) async {
try {
return Ok(await body());
} catch (error) {
} catch (error, stackTrace) {
// Silent failures are the worst kind: the UI shows its error state and
// nothing else records WHY. One line per failure, here at the single
// choke point every repository passes through.
Log.handle(error, stackTrace, 'repository fetch/decode');
return Err(mapException(error));
}
}
Expand Down
60 changes: 60 additions & 0 deletions lib/core/notifications/notification_channels.dart
Original file line number Diff line number Diff line change
Expand Up @@ -414,6 +414,28 @@ abstract final class NotificationChannels {
),
];

/// What [channel] will actually do when it fires.
///
/// Derived from the definition rather than written out beside it, so a
/// channel whose importance or sound changes cannot end up described by a
/// sentence that used to be true. Coarse on purpose: the question this
/// answers is "will I hear this at three in the morning", and the four
/// outcomes below are the only distinctions that change that answer.
static NotificationBehaviour behaviourOf(NotificationChannel channel) {
// Checked before sound and importance because it outranks both: a critical
// alert sounds at a volume the user does not control, through a silent
// switch and through Do Not Disturb.
if (channel.criticalAlerts ?? false) return NotificationBehaviour.overrides;
if (!(channel.playSound ?? true)) return NotificationBehaviour.silent;
// High is the threshold at which Android shows a heads-up banner, and the
// enum is declared in Android's own IMPORTANCE_* order, so comparing
// indices is comparing the platform constants.
final importance = channel.importance ?? NotificationImportance.Default;
return importance.index >= NotificationImportance.High.index
? NotificationBehaviour.alerts
: NotificationBehaviour.sounds;
}

/// The group a [channelKey] belongs to (`group_eew`, `group_eq`, …), or null
/// if the key isn't a known channel. Lets the tap router resolve a screen by
/// group so a new channel routes correctly without touching the mapping.
Expand All @@ -424,3 +446,41 @@ abstract final class NotificationChannels {
return null;
}
}

/// The coarse outcome of a channel firing — see
/// [NotificationChannels.behaviourOf].
enum NotificationBehaviour {
/// Sounds through the silent switch and Do Not Disturb — an iOS critical
/// alert, or Android's alarm stream at Max importance.
///
/// On iOS this is a promise the channel alone cannot keep: it also needs the
/// critical-alert grant, which the user can refuse. A caller showing this to
/// the user should check [NotificationService.criticalAllowed] before
/// presenting it as fact.
overrides,

/// Sound, and a banner over whatever is on screen — **while the phone is
/// not silenced**.
///
/// 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
/// `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
/// attached and the OS declines to play it.
///
/// Anything showing this to a user has to say so. Reported once as "震度速報
/// and 地震報告 have no sound" against a muted phone, and every static check
/// — the asset, its format, the bundling, the channel definition — came back
/// clean, because nothing was wrong.
alerts,

/// Sound but no banner — it waits in the notification list. Silenced with
/// the phone, exactly as [alerts] is.
sounds,

/// No sound at all.
silent,
}
107 changes: 107 additions & 0 deletions lib/core/notifications/notification_samples.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
/// The sample alert each channel's test notification posts.
///
/// **Deliberately Traditional Chinese, and deliberately not localized.** These
/// are not the app's own words — they are reproductions of what the backend
/// actually sends, and the backend sends Chinese to every device regardless of
/// its language. Translating them would make the test show something the user
/// will never receive, which is the one thing a test must not do. The page's
/// own chrome around them is localized normally.
///
/// Carried over verbatim from the legacy app's `assets/notify_test.json` so a
/// user who knew the old alerts recognises these. An asset is not needed: 21
/// fixed strings compile into the binary, cost no I/O, and cannot be missing at
/// runtime the way a mis-declared asset can.
///
/// Ideographic spaces are written as `\u3000` rather than pasted, because in
/// source they are indistinguishable from an ordinary space — and CWA's real
/// alerts use them for column alignment, so getting one wrong changes how the
/// sample reads.
library;

/// One channel's sample alert.
typedef NotificationSample = ({String title, String body});

/// Sample alerts, keyed by `channelKey`.
///
/// Covers the 21 push channels one-for-one. The locally-raised mesh channels
/// and the silent `background` service channel are absent on purpose: nothing
/// pushes them, so there is no server message to reproduce.
abstract final class NotificationSamples {
const NotificationSamples._();

/// The sample for [channelKey], or null for a channel with nothing to
/// reproduce.
static NotificationSample? of(String channelKey) => byChannel[channelKey];

static const Map<String, NotificationSample> byChannel = {
'eew_alert-important-v2': (
title: '🚨 《緊急地震速報 (氣象署發布) 》',
body: '花蓮縣壽豐鄉發生地震\u3000強烈搖晃警戒\n〈預估強烈搖晃地區〉\n花蓮\u3000南投\u3000臺東\u3000宜蘭',
),
'eew_alert-general-v2': (
title: '🚨 《緊急地震速報 (氣象署發布) 》',
body: '花蓮縣壽豐鄉發生地震\u3000強烈搖晃警戒\n〈預估強烈搖晃地區〉\n花蓮\u3000南投\u3000臺東\u3000宜蘭',
),
'eew_alert-silent-v2': (
title: '🚨 《緊急地震速報 (氣象署發布) 》',
body: '花蓮縣壽豐鄉發生地震\u3000強烈搖晃警戒\n〈預估強烈搖晃地區〉\n花蓮\u3000南投\u3000臺東\u3000宜蘭',
),
'eew-important-v2': (
title: '⚠️ 地震速報',
body: '10:15左右,花蓮縣壽豐鄉發生地震。震源深度10公里,地震規模M6.1,最大預估震度4。',
),
'eew-general-v2': (
title: '⚠️ 地震速報',
body: '10:15左右,花蓮縣壽豐鄉發生地震。震源深度10公里,地震規模M6.1,最大預估震度4。',
),
'eew-silence-v2': (
title: '⚠️ 地震速報',
body: '10:15左右,花蓮縣壽豐鄉發生地震。震源深度10公里,地震規模M6.1,最大預估震度4。',
),
'int_report-general-v2': (
title: '📨 震度速報 [07:36]',
body: '[震度 5弱]\u3000花蓮縣',
),
'int_report-silence-v2': (
title: '📨 震度速報 [07:36]',
body: '[震度 5弱]\u3000花蓮縣',
),
'eq-v2': (title: '📡 強震監視器', body: '臺南市歸仁區\u3000偵測到晃動'),
'report-general-v2': (
title: '🔔 地震報告 [小區域有感地震]',
body: '00:36左右,花蓮縣近海發生地震。震源深度23.8公里,地震規模M4.0,花蓮縣觀測到最大震度2。',
),
'report-silence-v2': (
title: '🔔 地震報告 [小區域有感地震]',
body: '00:36左右,花蓮縣近海發生地震。震源深度23.8公里,地震規模M4.0,花蓮縣觀測到最大震度2。',
),
'thunderstorm-important-v2': (
title: '⛈️ 山區暴雨',
body: '您所在區域附近有暴雨發生的機率,留意溪水暴漲並儘速遠離溪流,持續至8/4 16:34',
),
'thunderstorm-general-v2': (
title: '⛈️ 雷雨即時訊息',
body: '您所在區域附近有劇烈雷雨或降雨發生,請注意防範,持續至08/26 17:30',
),
'weather_major-important-v2': (title: '📊 臺南市歸仁區 天氣特報', body: '[發布]超大豪雨特報'),
'weather_minor-general-v2': (
title: '📊 臺南市歸仁區 天氣特報',
body: '[發布]大雨特報\n對流雲系發展旺盛,易有短延時強降雨,新北市已有豪雨發生,今(7日)晚至明(8日)晨基隆北海岸、彰化、雲林、南投、東半部地區及大臺北山區有局部大雨發生的機率,請注意雷擊及強陣風,山區慎防坍方、落石及溪水暴漲。',
),
'evacuation_major-important-v2': (
title: '🌧️ 防災資訊(短時極端降雨紀錄)',
body: '臺南市永康區(CAN040 國一N323K) 1 小時累積雨量達到 91.5 mm/hr,請注意自身安全。',
),
'evacuation_minor-general-v2': (
title: '⚠️ 防災資訊(河川水位-注意)',
body: '北寮橋 (水位 73.5m) 已達二級警戒,提高警覺,並密切注意水情變化。',
),
'tsunami-important-v2': (title: '🌊 海嘯警報發布', body: '海嘯警報已發布\n請儘速前往安全區域避難'),
'tsunami-general-v2': (title: '🌊 海嘯警報發布', body: '海嘯警報已發布\n請儘速前往安全區域避難'),
'tsunami-silent-v2': (
title: '🌊 太平洋海嘯消息',
body: '頃獲太平洋海嘯警報中心通報,2024年08月18日03時10分(臺灣時間),俄羅斯\u3000堪察加半島東部外海發生規模7﹒4地震,震央位於東經160﹒10度、北緯52﹒70度。該中心研判可能在太平洋地區引發海嘯威脅,氣象署將嚴密監視海嘯的後續影響,隨時提供最新資訊。',
),
'announcement-general-v2': (title: '📢 公告', body: '這是一則測試公告。'),
};
}
95 changes: 95 additions & 0 deletions lib/core/notifications/notification_service.dart
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import 'package:dpip/core/logging/log.dart';
import 'package:dpip/core/permissions/permission_outcome.dart';
import 'package:dpip/core/permissions/system_settings.dart';
import 'package:dpip/core/notifications/notification_channels.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/settings/setting_keys.dart';
Expand Down Expand Up @@ -251,6 +252,37 @@ class NotificationService {
return openNotificationSettingsPage();
}

/// Posts [channelKey]'s sample alert locally, so the user can hear and see
/// what that channel actually does on this device.
///
/// Local, not a round-trip through the backend: the thing being tested is the
/// last hop — the channel's sound, its importance, whether the OS lets it
/// through — and that hop is identical whether the notification came from
/// APNs or from here. Asking the server would only add a way for the test to
/// fail for a reason that has nothing to do with the answer.
///
/// Returns false when the channel has no sample or the OS refused it.
///
/// Two behaviours are deliberate rather than accidental:
///
/// * **It rings for real.** A channel with `criticalAlerts` overrides the
/// silent switch and Do Not Disturb, at a volume the user does not control.
/// Nothing here softens that, because a test that is quieter than the real
/// alert answers the wrong question. The page warns before the tap instead.
/// * **Tapping it navigates**, through the same [NotificationTaps] route
/// table as a real alert — so the test covers the tap as well, and the user
/// lands wherever the real one would have taken them.
Future<bool> showTest(String channelKey) async {
final content = testNotificationContent(channelKey);
if (content == null) return false;
try {
return await AwesomeNotifications().createNotification(content: content);
} catch (error, stackTrace) {
Log.handle(error, stackTrace, 'test notification for $channelKey');
return false;
}
}

Future<({int total, int rejected})> _initChannels() async {
final channels = NotificationChannels.channels;

Expand Down Expand Up @@ -489,6 +521,69 @@ class NotificationService {
}
}

/// The markers legacy stamped on every test notification, kept verbatim.
///
/// Not decoration, and not optional. The samples reproduce real CWA alerts
/// word for word, so an unmarked test is indistinguishable from the thing
/// itself: a 緊急地震速報 on a lock screen that nobody issued — photographed,
/// forwarded, and believed. The marker is the only thing standing between a
/// settings screen and a fake earthquake warning in circulation.
///
/// Chinese, like the body it prefixes. The samples are Chinese because the
/// backend only sends Chinese; a localized marker glued to an unlocalized
/// alert would read as two different messages.
const String testTitlePrefix = '[測試] ';
const String testBodyMarker = '***這是測試訊息***';

/// The notification a test tap posts for [channelKey], or null when the
/// channel has no sample to reproduce.
///
/// Split out from [NotificationService.showTest] so the part that decides what
/// the user sees can be tested without a platform channel — the same split
/// [contentFromData] already uses in this file.
NotificationContent? testNotificationContent(String channelKey) {
final sample = NotificationSamples.of(channelKey);
if (sample == null) return null;
return NotificationContent(
id: _testId(channelKey),
channelKey: channelKey,
title: '$testTitlePrefix${sample.title}',
// The separator is `<br>` on Android and a newline on iOS because awesome
// renders the Android body through `android.text.Html` (`Html.fromHtml`
// and the class itself are both in the shipped dex) and takes the iOS one
// literally. Legacy split it the same way; getting it wrong loses the line
// break rather than failing loudly.
//
// The sample's *own* newlines are left exactly as they are, on both
// platforms. On Android that means a multi-line CWA alert collapses to a
// single line — which is precisely what a real push from the backend does
// there, since it travels the same `Html.fromHtml` path. A test that read
// better than the alert it reproduces would be lying about the one thing
// it exists to show.
body: '$testBodyMarker${Platform.isIOS ? '\n' : '<br>'} ${sample.body}',
// The alerts this reproduces are several lines long; the default layout
// truncates them to one, which would make every sample look alike in the
// shade — the opposite of what the page is for.
notificationLayout: NotificationLayout.BigText,
);
}

/// A stable, per-channel notification id reserved for tests.
///
/// Negative, and derived from the channel's position in the catalogue, for
/// two reasons. Server alerts carry the backend's own positive ids, so a test
/// can never overwrite a real alert sitting in the shade. And one id *per
/// channel* — rather than legacy's single shared id — means re-testing a
/// channel replaces its own previous sample instead of stacking up, while two
/// different channels stay side by side where they can be compared. Comparing
/// them is most of the point.
int _testId(String channelKey) {
final index = NotificationChannels.channels.indexWhere(
(channel) => channel.channelKey == channelKey,
);
return -1000 - (index < 0 ? 0 : index);
}

/// Builds notification content from a message's `data` (preferred, legacy
/// format) falling back to its `notification` block, or null when there's
/// nothing to show.
Expand Down
Loading
Loading