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
26 changes: 13 additions & 13 deletions .github/workflows/android.yml
Original file line number Diff line number Diff line change
Expand Up @@ -90,19 +90,19 @@ jobs:
bash tool/dev/deps.sh
bash tool/dev/codegen.sh

- name: Decode keystore
run: |
echo "${{ secrets.KEYSTORE_BASE64 }}" | base64 --decode > android/app/my-release-key.jks

- name: Create key.properties
run: |
cat > android/key.properties << EOF
storePassword=${{ secrets.KEYSTORE_PASSWORD }}
keyPassword=${{ secrets.KEY_PASSWORD }}
keyAlias=${{ secrets.KEY_ALIAS }}
storeFile=my-release-key.jks
EOF

# No keystore, and deliberately so. This job builds the artifact nobody
# installs — release.yml signs and uploads the one that ships — so the
# release keystore buys nothing here, and asking for it is what breaks
# the job outright: a pull request opened from a fork is handed no
# repository secrets at all, so `secrets.KEYSTORE_BASE64` is the empty
# string, android/key.properties is written with four empty values, and
# Gradle fails inside packageRelease with a keystore error that says
# nothing about forks. Every other check on such a pull request passes,
# including the iOS build, which never codesigns.
#
# android/app/build.gradle.kts already falls back to the debug signing
# config when android/key.properties is absent, so what comes out is the
# same release build, debug-signed.
- name: Build Release APK
run: bash tool/dev/build.sh android

Expand Down
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 @@ -150,6 +150,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 @@ -18,6 +18,9 @@ const String _monitorDemoSevereRaw = String.fromEnvironment(
const String _startupEewDemoRaw = String.fromEnvironment(
'DPIP_DEMO_STARTUP_EEW',
);
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 Down Expand Up @@ -48,3 +51,11 @@ const bool kStartupEewDemoEnabled =
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 @@ -35,6 +35,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 @@ -78,6 +79,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;
}
}
73 changes: 71 additions & 2 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/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';
Expand All @@ -34,10 +35,31 @@ 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() {
_foregroundEewGate = this.foregroundEewGate;
}

final SettingsStore _settings;

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

/// The same gate, reachable from [onFcmSilentData].
///
/// That function is a top-level entry point — awesome_notifications_fcm
/// calls it with no service instance to reach — so the gate the visible
/// monitor speaks through has to be published somewhere it can see. It stays
/// null on the background isolate, which never runs this constructor and
/// where nothing is speaking; the foreground branch there displays
/// immediately when it is null, so a missing instance can only ever mean the
/// warning arrives sooner, never later.
static ForegroundEewAnnouncementGate? _foregroundEewGate;

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

Expand Down Expand Up @@ -441,6 +463,38 @@ class NotificationService {
);
}

/// 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 Expand Up @@ -723,5 +777,20 @@ Future<void> onFcmSilentData(FcmSilentData silentData) async {

final content = contentFromData(data.cast<String, dynamic>());
if (content == null) return;
await AwesomeNotifications().createNotification(content: content);

Future<void> display() =>
AwesomeNotifications().createNotification(content: content);

// A foreground EEW is the one case that waits: the visible monitor may be
// speaking the estimated intensity, and the channel's warning sound must not
// talk over it. Every other lifecycle and every other channel displays
// straight away, and the gate's own timeout bounds this one.
final gate = NotificationService._foregroundEewGate;
if (gate != null &&
silentData.createdLifeCycle == NotificationLifeCycle.Foreground &&
(content.channelKey?.startsWith('eew') ?? false)) {
await gate.submit(display);
return;
}
await display();
}
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 @@ -161,12 +161,14 @@ class StartupEewDemoSource extends RealtimeSource<List<Eew>> {
}

/// 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