Skip to content
Draft
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
7 changes: 7 additions & 0 deletions android/app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,13 @@ android {
}

buildTypes {
debug {
// defaultConfig keeps release artifacts arm64-only, but Android
// emulators on Intel/AMD hosts need Flutter's x86_64 engine.
ndk {
abiFilters.add("x86_64")
}
}
release {
signingConfig =
if (keystorePropertiesFile.exists()) {
Expand Down
4 changes: 4 additions & 0 deletions android/app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,10 @@

In particular, this is used by the Flutter engine in io.flutter.plugin.text.ProcessTextPlugin. -->
<queries>
<!-- Android 11+ package visibility for the system TTS engine. -->
<intent>
<action android:name="android.intent.action.TTS_SERVICE"/>
</intent>
<intent>
<action android:name="android.intent.action.PROCESS_TEXT"/>
<data android:mimeType="text/plain"/>
Expand Down
11 changes: 11 additions & 0 deletions lib/core/build/demo_flags.dart
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,9 @@ const String _monitorDemoRaw = String.fromEnvironment('DPIP_DEMO_MONITOR');
const String _monitorDemoSevereRaw = String.fromEnvironment(
'DPIP_DEMO_MONITOR_SEVERE',
);
const String _monitorDemoSoundRaw = String.fromEnvironment(
'DPIP_DEMO_MONITOR_SOUND',
);

/// Whether the 強震監視器 demo feeds are on: debug builds launched with
/// `--dart-define=DPIP_DEMO_MONITOR=true` (or `=1`). The flag is forced off
Expand All @@ -33,3 +36,11 @@ const bool kMonitorDemoEnabled =
const bool kMonitorDemoSevereEnabled =
(_monitorDemoSevereRaw == 'true' || _monitorDemoSevereRaw == '1') &&
kDebugMode;

/// Whether the monitor demo submits one foreground notification through the
/// real EEW announcement gate. Kept separate because the original alarm sound
/// is deliberately disruptive. It is inert outside a debug monitor demo.
const bool kMonitorDemoSoundEnabled =
kMonitorDemoEnabled &&
(_monitorDemoSoundRaw == 'true' || _monitorDemoSoundRaw == '1') &&
kDebugMode;
5 changes: 5 additions & 0 deletions lib/core/di/core_providers.dart
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import 'package:dpip/core/settings/region_store.dart';
import 'package:dpip/core/settings/color_vision_controller.dart';
import 'package:dpip/core/settings/display_settings.dart';
import 'package:dpip/core/settings/theme_controller.dart';
import 'package:dpip/core/speech/speech_service.dart';
import 'package:dpip/shared/map/map_tile_cache.dart';
import 'package:provider/provider.dart';
import 'package:provider/single_child_widget.dart';
Expand Down Expand Up @@ -70,6 +71,10 @@ List<SingleChildWidget> coreProviders(SharedDeps deps) => [
ChangeNotifierProvider<PermissionHealth>.value(value: deps.permissionHealth),
Provider<RealtimeService>.value(value: deps.realtimeService),
Provider<NotificationService>.value(value: deps.notificationService),
Provider<SpeechService>(
create: (_) => SystemSpeechService(),
dispose: (_, speech) => speech.dispose(),
),
Provider<MeshtasticService>.value(value: deps.meshtastic),
ChangeNotifierProvider<MeshLink>.value(value: deps.meshLink),
ChangeNotifierProvider<MeshAlerts>.value(value: deps.meshAlerts),
Expand Down
102 changes: 102 additions & 0 deletions lib/core/notifications/foreground_eew_announcement_gate.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
/// Coordinates foreground EEW speech with the notification that plays its
/// configured warning sound.
library;

import 'dart:async';

/// Holds the newest foreground EEW notification while an announcement is
/// speaking, then releases it when the newest announcement completes.
///
/// Background delivery never passes through this gate. A bounded timeout is a
/// safety fallback: a broken or unavailable TTS engine must not suppress the
/// warning notification indefinitely.
class ForegroundEewAnnouncementGate {
// The monitor controller gives system TTS eight seconds to finish. Keep the
// independent notification fallback beyond that bound so a slow but healthy
// voice cannot overlap the alarm; the fallback still prevents a wedged
// engine from suppressing the warning indefinitely.
ForegroundEewAnnouncementGate({this.maxHold = const Duration(seconds: 10)});

final Duration maxHold;

bool _active = false;
bool _announcing = false;
int _generation = 0;
Future<void> Function()? _pending;
Timer? _timer;

/// Whether the visible monitor currently owns foreground EEW sequencing.
bool get active => _active;

/// Enables or disables sequencing. Disabling immediately releases anything
/// pending so leaving the monitor can never swallow a warning.
void setActive(bool value) {
if (_active == value) return;
_active = value;
if (!value) {
_generation++;
_announcing = false;
unawaited(_release());
}
}

/// Marks a new report as the announcement that must finish before warning
/// sound playback. The returned generation identifies that exact report.
int beginAnnouncement() {
_announcing = true;
final generation = ++_generation;
// A notification retained for the previous serial now belongs to the
// latest speech sequence. Give that sequence its own full safety window.
if (_pending != null) {
_timer?.cancel();
_timer = Timer(maxHold, () => unawaited(_release()));
}
return generation;
}

/// Displays immediately unless the monitor is active and an announcement is
/// in flight. At most the newest notification is retained during rapid EEW
/// report updates, matching the UI and spoken latest-report policy.
Future<void> submit(Future<void> Function() display) async {
if (!_active || !_announcing) {
await display();
return;
}

_pending = display;
_timer?.cancel();
_timer = Timer(maxHold, () => unawaited(_release()));
}

/// Releases the pending warning only when [generation] still represents the
/// newest report. Completion from interrupted speech is ignored.
Future<void> completeAnnouncement(int generation) async {
if (generation != _generation) return;
_announcing = false;
await _release();
}

/// Abandons the current speech wait and releases its pending warning.
void cancelAnnouncement() {
_generation++;
_announcing = false;
unawaited(_release());
}

Future<void> _release() async {
_timer?.cancel();
_timer = null;
_announcing = false;
final display = _pending;
_pending = null;
if (display != null) await display();
}

/// Cancels timers. Call only when the owning notification service is torn
/// down; ordinary monitor deactivation must use [setActive] so it flushes.
void dispose() {
_timer?.cancel();
_timer = null;
_pending = null;
}
}
64 changes: 57 additions & 7 deletions lib/core/notifications/notification_service.dart
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,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/foreground_eew_announcement_gate.dart';
import 'package:dpip/core/notifications/notification_tap.dart';
import 'package:dpip/core/notifications/notification_taps.dart';
import 'package:dpip/core/settings/setting_keys.dart';
Expand All @@ -31,10 +32,17 @@ const String _fallbackChannelKey = 'announcement-general-v2';
/// [NotificationTaps]. A `notification`-payload message is displayed by the OS
/// directly (its tap arrives via `onMessageOpenedApp`).
class NotificationService {
NotificationService(this._settings);
NotificationService(
this._settings, {
ForegroundEewAnnouncementGate? foregroundEewGate,
}) : foregroundEewGate = foregroundEewGate ?? ForegroundEewAnnouncementGate();

final SettingsStore _settings;

/// Sequences foreground EEW speech before the notification channel sound.
/// Background and terminated delivery bypass this object entirely.
final ForegroundEewAnnouncementGate foregroundEewGate;

/// The last push token, or null before registration.
String? get token => _settings.getString(SettingKeys.pushToken);

Expand Down Expand Up @@ -311,12 +319,9 @@ class NotificationService {
final messaging = FirebaseMessaging.instance;

FirebaseMessaging.onBackgroundMessage(onBackgroundMessage);
FirebaseMessaging.onMessage.listen((message) {
final content = contentFromMessage(message);
if (content != null) {
AwesomeNotifications().createNotification(content: content);
}
});
FirebaseMessaging.onMessage.listen(
(message) => unawaited(_showForegroundMessage(message)),
);
FirebaseMessaging.onMessageOpenedApp.listen((m) => _routeTap(m.data));

final initial = await messaging.getInitialMessage();
Expand All @@ -341,6 +346,51 @@ class NotificationService {
unawaited(_fetchToken());
}

Future<void> _showForegroundMessage(RemoteMessage message) async {
final content = contentFromMessage(message);
if (content == null) return;

Future<void> display() =>
AwesomeNotifications().createNotification(content: content);
if (content.channelKey?.startsWith('eew') ?? false) {
await foregroundEewGate.submit(display);
} else {
await display();
}
}

/// Submits a debug monitor warning through the same foreground EEW gate as
/// an FCM message. The caller is compile-time gated by the demo sound flag;
/// this guard also makes an accidental release call inert.
Future<void> showDebugEewWarning({
required String title,
required String body,
}) async {
if (!kDebugMode) return;
await foregroundEewGate.submit(() async {
final created = await AwesomeNotifications().createNotification(
content: NotificationContent(
id: 570057,
channelKey: 'eew_alert-important-v2',
title: title,
body: body,
wakeUpScreen: true,
category: NotificationCategory.Alarm,
payload: const {
'channel': 'eew_alert-important-v2',
'id': 'demo-monitor-sound',
},
),
);
if (!created) {
Log.warning(
'monitor demo warning was rejected — notification permission or '
'channel settings may be disabled',
);
}
});
}

/// Fetches the push token and persists it as [SettingKeys.pushToken] —
/// the identifier every backend registration call (`/v2/location`,
/// `/v2/notify`) keys on.
Expand Down
65 changes: 65 additions & 0 deletions lib/core/speech/speech_service.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
/// System text-to-speech abstraction used by foreground safety announcements.
library;

import 'dart:async';

import 'package:flutter/foundation.dart';
import 'package:flutter_tts/flutter_tts.dart';

/// Speaks short phrases through the platform speech engine.
abstract interface class SpeechService {
/// Stops any current phrase and speaks [text] to completion.
Future<void> speak(String text, {required String languageTag});

/// Stops the current phrase, if any.
Future<void> stop();

/// Releases transient speech state owned by this service.
void dispose();
}

/// Android `TextToSpeech` / iOS `AVSpeechSynthesizer` implementation.
class SystemSpeechService implements SpeechService {
SystemSpeechService({FlutterTts? engine}) : _engine = engine ?? FlutterTts();

final FlutterTts _engine;
bool _configured = false;

Future<void> _configure() async {
if (_configured) return;
await _engine.awaitSpeakCompletion(true);
if (defaultTargetPlatform == TargetPlatform.iOS) {
// The plugin's default iOS category follows the Silent switch. A
// foreground disaster announcement must remain audible there as well;
// voicePrompt + duckOthers keeps it intelligible without permanently
// taking ownership of another app's audio session.
await _engine.setIosAudioCategory(IosTextToSpeechAudioCategory.playback, [
IosTextToSpeechAudioCategoryOptions.duckOthers,
], IosTextToSpeechAudioMode.voicePrompt);
}
// Maximise the utterance within the user's selected media-volume level.
// Changing the device's stream volume would be intrusive and would persist
// after the warning, so that remains under the user's control.
await _engine.setVolume(1.0);
_configured = true;
}

@override
Future<void> speak(String text, {required String languageTag}) async {
await _configure();
await _engine.stop();
await _engine.setLanguage(languageTag);
final result = await _engine.speak(text);
if (result != 1) throw StateError('System TTS rejected speech');
}

@override
Future<void> stop() async {
await _engine.stop();
}

@override
void dispose() {
unawaited(stop());
}
}
8 changes: 5 additions & 3 deletions lib/features/earthquake/data/monitor_demo.dart
Original file line number Diff line number Diff line change
Expand Up @@ -115,12 +115,14 @@ abstract final class MonitorDemo {
}

/// Polls as an always-live EEW alert for [MonitorDemo]'s event, bumping the
/// serial every couple of seconds so the feed visibly updates and the monitor
/// cards re-render while the wavefront keeps expanding.
/// serial every twelve seconds so the feed visibly updates while leaving even
/// the slower Google zh-TW voice enough time to finish. A two-second demo
/// cadence kept interrupting the phrase at its comma; six seconds still cut
/// the final word after accounting for that engine's startup latency.
class DemoEewSource extends RealtimeSource<List<Eew>> {
DemoEewSource(this._reports) {
_alerts = [_build(1)];
_tick = Timer.periodic(const Duration(seconds: 2), (_) {
_tick = Timer.periodic(const Duration(seconds: 12), (_) {
_alerts = [_build(++_serial)];
});
unawaited(_loadReport());
Expand Down
Loading
Loading