diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml index 4724976d5..6e5039ade 100644 --- a/.github/workflows/android.yml +++ b/.github/workflows/android.yml @@ -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 diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 1cc1be24d..7c08a9c36 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -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()) { diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 88027c5ab..eb40eec1c 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -150,6 +150,10 @@ In particular, this is used by the Flutter engine in io.flutter.plugin.text.ProcessTextPlugin. --> + + + + diff --git a/android/app/src/main/kotlin/com/exptech/dpip/MainActivity.kt b/android/app/src/main/kotlin/com/exptech/dpip/MainActivity.kt index 6b4660184..6695281c7 100644 --- a/android/app/src/main/kotlin/com/exptech/dpip/MainActivity.kt +++ b/android/app/src/main/kotlin/com/exptech/dpip/MainActivity.kt @@ -62,5 +62,8 @@ class MainActivity : FlutterActivity() { MethodChannel(messenger, PlainChannelsChannel.NAME) .setMethodCallHandler(PlainChannelsChannel(applicationContext)) + + MethodChannel(messenger, SpeechChannel.NAME) + .setMethodCallHandler(SpeechChannel(applicationContext)) } } diff --git a/android/app/src/main/kotlin/com/exptech/dpip/SpeechChannel.kt b/android/app/src/main/kotlin/com/exptech/dpip/SpeechChannel.kt new file mode 100644 index 000000000..61dc00ee3 --- /dev/null +++ b/android/app/src/main/kotlin/com/exptech/dpip/SpeechChannel.kt @@ -0,0 +1,173 @@ +package com.exptech.dpip + +import android.content.Context +import android.media.AudioManager +import android.os.Bundle +import android.os.Handler +import android.os.Looper +import android.speech.tts.TextToSpeech +import android.speech.tts.UtteranceProgressListener +import io.flutter.plugin.common.MethodCall +import io.flutter.plugin.common.MethodChannel +import java.util.Locale +import java.util.concurrent.atomic.AtomicInteger + +/** + * Speaks a short phrase and reports when it has finished, for the foreground + * EEW announcement that must complete before the warning sound plays. + * + * `android.speech.tts.TextToSpeech` directly rather than a package: the only + * pub package with this API ships no Swift Package Manager support, which the + * iOS half of this app needs, so both halves are owned here instead. + * + * One utterance at a time. A `speak` while another is in flight flushes it, and + * the flushed call still returns normally — the caller's contract is + * latest-report-wins, so a superseded phrase is the expected path rather than + * an error. Every `speak` replies exactly once, which the channel requires, and + * every reply is posted to the main thread: `UtteranceProgressListener` fires + * on a binder thread, and a `MethodChannel.Result` answered from there is a + * crash rather than a warning. + */ +class SpeechChannel(context: Context) : MethodChannel.MethodCallHandler { + + companion object { + const val NAME = "com.exptech.dpip/speech" + } + + private val main = Handler(Looper.getMainLooper()) + private val ids = AtomicInteger() + + /** The reply owed to the utterance in flight, or null when none is. */ + private var pending: MethodChannel.Result? = null + private var pendingId: String? = null + + /** + * The engine's init result, or null while it is still starting. + * + * `TextToSpeech` binds to the system engine asynchronously and rejects + * everything until it is connected. The first announcement is the one that + * matters most, so calls that arrive before then wait here rather than + * being refused. + */ + private var initStatus: Int? = null + private val waiting = ArrayDeque<(Boolean) -> Unit>() + + private val tts = + TextToSpeech(context.applicationContext) { status -> + main.post { + initStatus = status + val ready = status == TextToSpeech.SUCCESS + val queued = waiting.toList() + waiting.clear() + queued.forEach { it(ready) } + } + } + + init { + tts.setOnUtteranceProgressListener( + object : UtteranceProgressListener() { + override fun onStart(utteranceId: String?) = Unit + + override fun onDone(utteranceId: String?) = settle(utteranceId) + + // Abstract in the Java base class, so it has to be here even + // though the two-argument form below replaced it. + @Suppress("OVERRIDE_DEPRECATION") + override fun onError(utteranceId: String?) = settle(utteranceId) + + override fun onError(utteranceId: String?, errorCode: Int) = settle(utteranceId) + + override fun onStop(utteranceId: String?, interrupted: Boolean) = + settle(utteranceId) + } + ) + } + + override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) { + when (call.method) { + "speak" -> { + val text = call.argument("text") + val language = call.argument("language") + if (text == null || language == null) { + result.error("BAD_ARGUMENTS", "speak needs text and language", null) + return + } + whenReady { ready -> + if (!ready) { + result.error("UNAVAILABLE", "No system speech engine", null) + return@whenReady + } + speak(text, language, result) + } + } + + "stop" -> { + tts.stop() + settleNow(null) + result.success(null) + } + + else -> result.notImplemented() + } + } + + private fun speak(text: String, language: String, result: MethodChannel.Result) { + // Supersede first, so the previous call is answered before this one can + // take its place — QUEUE_FLUSH's onStop would otherwise settle *this* + // reply against the outgoing utterance's id. + settleNow(null) + + // A language the device has no voice data for leaves whatever the + // engine defaults to in place: a phrase in the wrong accent still + // carries the intensity, and silence does not. + tts.setLanguage(Locale.forLanguageTag(language)) + + val id = ids.incrementAndGet().toString() + // Loudest within the user's media volume, on the stream the warning + // sound uses. Changing the device volume would be intrusive and would + // outlast the warning, so that stays theirs. + val params = + Bundle().apply { + putFloat(TextToSpeech.Engine.KEY_PARAM_VOLUME, 1.0f) + putInt(TextToSpeech.Engine.KEY_PARAM_STREAM, AudioManager.STREAM_MUSIC) + } + + pending = result + pendingId = id + if (tts.speak(text, TextToSpeech.QUEUE_FLUSH, params, id) != TextToSpeech.SUCCESS) { + pending = null + pendingId = null + result.error("REJECTED", "System TTS refused the utterance", null) + } + } + + /** + * Answers the utterance in flight, if [utteranceId] is still the current + * one. Hops to the main thread first, because the progress listener that + * calls it does not run there. + */ + private fun settle(utteranceId: String?) { + main.post { settleNow(utteranceId) } + } + + /** + * [settle] without the hop, for the two callers that are already on the + * main thread and must answer *before* they install a new reply — a posted + * settle would run after that and cancel the wrong one. + */ + private fun settleNow(utteranceId: String?) { + if (utteranceId != null && utteranceId != pendingId) return + pending?.success(null) + pending = null + pendingId = null + } + + private fun whenReady(action: (Boolean) -> Unit) { + val status = initStatus + if (status == null) { + waiting.add(action) + return + } + action(status == TextToSpeech.SUCCESS) + } +} diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index a4738f9d6..a642d8e68 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -29,6 +29,7 @@ AA0000000000000000000E02 /* ScreenWakePlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0000000000000000000E01 /* ScreenWakePlugin.swift */; }; AA0000000000000000000G02 /* LocationTrackStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0000000000000000000G01 /* LocationTrackStore.swift */; }; AA0000000000000000000F02 /* ApnsTokenPlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0000000000000000000F01 /* ApnsTokenPlugin.swift */; }; + AA0000000000000000001002 /* SpeechPlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0000000000000000001001 /* SpeechPlugin.swift */; }; AA0000000000000000000D02 /* DeviceInfoPlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0000000000000000000D01 /* DeviceInfoPlugin.swift */; }; AE92AD9862B7A721B0924557 /* eew.aiff in Resources */ = {isa = PBXBuildFile; fileRef = 7A6E88CB92902C0CACB07792 /* eew.aiff */; }; CAC4EF00000000000000B001 /* MapCachePlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAC4EF00000000000000B002 /* MapCachePlugin.swift */; }; @@ -97,6 +98,7 @@ AA0000000000000000000E01 /* ScreenWakePlugin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScreenWakePlugin.swift; sourceTree = ""; }; AA0000000000000000000G01 /* LocationTrackStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocationTrackStore.swift; sourceTree = ""; }; AA0000000000000000000F01 /* ApnsTokenPlugin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ApnsTokenPlugin.swift; sourceTree = ""; }; + AA0000000000000000001001 /* SpeechPlugin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpeechPlugin.swift; sourceTree = ""; }; AA0000000000000000000D01 /* DeviceInfoPlugin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceInfoPlugin.swift; sourceTree = ""; }; B916667D1B2356583B174E80 /* normal.aiff */ = {isa = PBXFileReference; includeInIndex = 1; path = Sounds/normal.aiff; sourceTree = ""; }; CAC4EF00000000000000B002 /* MapCachePlugin.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = MapCachePlugin.swift; sourceTree = ""; }; @@ -182,6 +184,7 @@ AA0000000000000000000E01 /* ScreenWakePlugin.swift */, AA0000000000000000000G01 /* LocationTrackStore.swift */, AA0000000000000000000F01 /* ApnsTokenPlugin.swift */, + AA0000000000000000001001 /* SpeechPlugin.swift */, AA0000000000000000000D01 /* DeviceInfoPlugin.swift */, 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */, 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */, @@ -378,6 +381,7 @@ AA0000000000000000000E02 /* ScreenWakePlugin.swift in Sources */, AA0000000000000000000G02 /* LocationTrackStore.swift in Sources */, AA0000000000000000000F02 /* ApnsTokenPlugin.swift in Sources */, + AA0000000000000000001002 /* SpeechPlugin.swift in Sources */, AA0000000000000000000D02 /* DeviceInfoPlugin.swift in Sources */, 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */, 7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */, diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift index c315634bb..59faea566 100644 --- a/ios/Runner/AppDelegate.swift +++ b/ios/Runner/AppDelegate.swift @@ -34,6 +34,7 @@ import UserNotifications StorageScanPlugin.register(with: registry.registrar(forPlugin: "StorageScanPlugin")!) ScreenWakePlugin.register(with: registry.registrar(forPlugin: "ScreenWakePlugin")!) ApnsTokenPlugin.register(with: registry.registrar(forPlugin: "ApnsTokenPlugin")!) + SpeechPlugin.register(with: registry.registrar(forPlugin: "SpeechPlugin")!) BackgroundLocationPlugin.register( with: registry.registrar(forPlugin: "BackgroundLocationPlugin")!) BackgroundExecutionPlugin.register( diff --git a/ios/Runner/SpeechPlugin.swift b/ios/Runner/SpeechPlugin.swift new file mode 100644 index 000000000..bf821fc7e --- /dev/null +++ b/ios/Runner/SpeechPlugin.swift @@ -0,0 +1,108 @@ +import AVFoundation +import Flutter + +/// Speaks a short phrase and reports when it has finished, for the foreground +/// EEW announcement that must complete before the warning sound plays. +/// +/// `AVSpeechSynthesizer` directly rather than a package: the only pub package +/// with this API ships no Swift Package Manager support, and this app builds +/// without CocoaPods. +/// +/// One utterance at a time. A `speak` while another is in flight cancels it, +/// and the cancelled call still returns normally — the caller's contract is +/// latest-report-wins, so a superseded phrase is the expected path rather than +/// an error. Every `speak` replies exactly once, which the channel requires. +public class SpeechPlugin: NSObject, FlutterPlugin, AVSpeechSynthesizerDelegate { + public static func register(with registrar: FlutterPluginRegistrar) { + let channel = FlutterMethodChannel( + name: "com.exptech.dpip/speech", + binaryMessenger: registrar.messenger()) + registrar.addMethodCallDelegate(SpeechPlugin(), channel: channel) + } + + private let synthesizer = AVSpeechSynthesizer() + private var pending: FlutterResult? + + override init() { + super.init() + synthesizer.delegate = self + } + + public func handle( + _ call: FlutterMethodCall, result: @escaping FlutterResult + ) { + switch call.method { + case "speak": + guard let arguments = call.arguments as? [String: Any], + let text = arguments["text"] as? String, + let language = arguments["language"] as? String + else { + result( + FlutterError( + code: "BAD_ARGUMENTS", message: "speak needs text and language", details: nil)) + return + } + speak(text, language: language, result: result) + case "stop": + synthesizer.stopSpeaking(at: .immediate) + settle() + deactivateSession() + result(nil) + default: + result(FlutterMethodNotImplemented) + } + } + + private func speak(_ text: String, language: String, result: @escaping FlutterResult) { + // Supersede first, so the previous call is answered before this one can + // take its place — `didCancel` for it would otherwise settle *this* result. + synthesizer.stopSpeaking(at: .immediate) + settle() + + // The default category follows the Silent switch. A foreground disaster + // announcement has to stay audible there too; `.playback` with + // `.voicePrompt` and `duckOthers` keeps it intelligible without taking + // ownership of another app's audio for longer than the phrase lasts. + let session = AVAudioSession.sharedInstance() + try? session.setCategory(.playback, mode: .voicePrompt, options: [.duckOthers]) + try? session.setActive(true) + + let utterance = AVSpeechUtterance(string: text) + // A language the device has no voice for leaves the system default in + // place: a phrase in the wrong accent still carries the intensity, and + // silence does not. + if let voice = AVSpeechSynthesisVoice(language: language) { + utterance.voice = voice + } + // Loudest within the user's media volume. Changing the device volume would + // be intrusive and would outlast the warning, so that stays theirs. + utterance.volume = 1.0 + + pending = result + synthesizer.speak(utterance) + } + + private func settle() { + guard let result = pending else { return } + pending = nil + result(nil) + } + + private func deactivateSession() { + try? AVAudioSession.sharedInstance().setActive( + false, options: [.notifyOthersOnDeactivation]) + } + + public func speechSynthesizer( + _ synthesizer: AVSpeechSynthesizer, didFinish utterance: AVSpeechUtterance + ) { + settle() + deactivateSession() + } + + public func speechSynthesizer( + _ synthesizer: AVSpeechSynthesizer, didCancel utterance: AVSpeechUtterance + ) { + settle() + } +} diff --git a/lib/core/build/demo_flags.dart b/lib/core/build/demo_flags.dart index b1dd11fb0..cc4e67a2f 100644 --- a/lib/core/build/demo_flags.dart +++ b/lib/core/build/demo_flags.dart @@ -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 @@ -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; diff --git a/lib/core/di/core_providers.dart b/lib/core/di/core_providers.dart index e7d0ca954..3057f276f 100644 --- a/lib/core/di/core_providers.dart +++ b/lib/core/di/core_providers.dart @@ -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'; @@ -78,6 +79,10 @@ List coreProviders(SharedDeps deps) => [ ChangeNotifierProvider.value(value: deps.permissionHealth), Provider.value(value: deps.realtimeService), Provider.value(value: deps.notificationService), + Provider( + create: (_) => SystemSpeechService(), + dispose: (_, speech) => speech.dispose(), + ), Provider.value(value: deps.meshtastic), ChangeNotifierProvider.value(value: deps.meshLink), ChangeNotifierProvider.value(value: deps.meshAlerts), diff --git a/lib/core/notifications/foreground_eew_announcement_gate.dart b/lib/core/notifications/foreground_eew_announcement_gate.dart new file mode 100644 index 000000000..631f96ede --- /dev/null +++ b/lib/core/notifications/foreground_eew_announcement_gate.dart @@ -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 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 submit(Future 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 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 _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; + } +} diff --git a/lib/core/notifications/notification_service.dart b/lib/core/notifications/notification_service.dart index c04a35af5..106427fbe 100644 --- a/lib/core/notifications/notification_service.dart +++ b/lib/core/notifications/notification_service.dart @@ -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'; @@ -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); @@ -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 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. @@ -723,5 +777,20 @@ Future onFcmSilentData(FcmSilentData silentData) async { final content = contentFromData(data.cast()); if (content == null) return; - await AwesomeNotifications().createNotification(content: content); + + Future 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(); } diff --git a/lib/core/speech/speech_service.dart b/lib/core/speech/speech_service.dart new file mode 100644 index 000000000..629b6c481 --- /dev/null +++ b/lib/core/speech/speech_service.dart @@ -0,0 +1,55 @@ +/// System text-to-speech abstraction used by foreground safety announcements. +library; + +import 'dart:async'; + +import 'package:flutter/services.dart'; + +/// Speaks short phrases through the platform speech engine. +abstract interface class SpeechService { + /// Stops any current phrase and speaks [text] to completion. + Future speak(String text, {required String languageTag}); + + /// Stops the current phrase, if any. + Future stop(); + + /// Releases transient speech state owned by this service. + void dispose(); +} + +/// Android `TextToSpeech` / iOS `AVSpeechSynthesizer`, over an app-owned +/// platform channel. +/// +/// A channel rather than a package, like the rest of the native surface: the +/// only TTS package on pub with the API this needs (`flutter_tts`) ships no +/// Swift Package Manager support, and this project builds iOS without +/// CocoaPods (README → 參與開發). Adopting it would have pulled a Podfile back +/// in through a transitive dependency, for two calls whose native side is +/// thirty lines each. +class SystemSpeechService implements SpeechService { + SystemSpeechService({MethodChannel? channel}) + : _channel = channel ?? const MethodChannel(channelName); + + /// The channel `SpeechChannel.kt` and `SpeechPlugin.swift` answer on. + static const String channelName = 'com.exptech.dpip/speech'; + + final MethodChannel _channel; + + /// Speaks [text], completing when the utterance finishes. + /// + /// A phrase superseded by a later [speak] — or cut short by [stop] — also + /// completes normally rather than throwing: latest-report-wins is the + /// expected path here, not a failure. Only an engine that is absent or + /// refuses the utterance raises. + @override + Future speak(String text, {required String languageTag}) => _channel + .invokeMethod('speak', {'text': text, 'language': languageTag}); + + @override + Future stop() => _channel.invokeMethod('stop'); + + @override + void dispose() { + unawaited(stop()); + } +} diff --git a/lib/features/earthquake/data/monitor_demo.dart b/lib/features/earthquake/data/monitor_demo.dart index ae4569088..674d31dd1 100644 --- a/lib/features/earthquake/data/monitor_demo.dart +++ b/lib/features/earthquake/data/monitor_demo.dart @@ -161,12 +161,14 @@ class StartupEewDemoSource extends RealtimeSource> { } /// 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> { 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()); diff --git a/lib/features/map/presentation/monitor_eew_announcement_controller.dart b/lib/features/map/presentation/monitor_eew_announcement_controller.dart new file mode 100644 index 000000000..bd8bd2630 --- /dev/null +++ b/lib/features/map/presentation/monitor_eew_announcement_controller.dart @@ -0,0 +1,127 @@ +/// Latest-report-wins speech state machine for the visible seismic monitor. +library; + +import 'dart:async'; + +import 'package:dpip/core/logging/log.dart'; +import 'package:dpip/core/notifications/foreground_eew_announcement_gate.dart'; +import 'package:dpip/core/realtime/realtime_state.dart'; +import 'package:dpip/core/speech/speech_service.dart'; +import 'package:dpip/features/earthquake/domain/eew.dart'; + +/// A shaking scale together with whether it is local or the max fallback. +typedef SpokenEewEstimate = ({int scale, bool isLocal}); + +/// Resolves the phrase after a local/fallback estimate has been selected. +typedef EewSpeechFormatter = String Function(SpokenEewEstimate estimate); + +/// Announces each new active EEW serial while the monitor is visible. +/// +/// Every accepted update stops the previous utterance immediately. Async +/// estimate/speech completions carry a generation, so an obsolete report can +/// neither speak late nor release the warning sound for a newer report. +class MonitorEewAnnouncementController { + MonitorEewAnnouncementController( + this._speech, + this._gate, + this._estimate, { + // Android's system engine can spend several seconds starting an utterance. + // The stock Google zh-TW voice did not finish even inside five seconds on + // the emulator, while en-US and ja-JP did. Eight still bounds a wedged + // engine without overriding the user's system speech rate. Notification + // playback has its own, slightly longer safety fallback in the foreground + // gate, so a healthy slow voice never overlaps the alarm. + this.speechTimeout = const Duration(seconds: 8), + }); + + final SpeechService _speech; + final ForegroundEewAnnouncementGate _gate; + final Future Function(Eew alert) _estimate; + final Duration speechTimeout; + + final Map _seenSerials = {}; + bool _active = false; + bool _hasCurrentAlert = false; + int _generation = 0; + + /// Activates announcements only for the foreground, visible monitor. + void setActive(bool value) { + if (_active == value) return; + _active = value; + _generation++; + _gate.setActive(value); + if (!value) { + _hasCurrentAlert = false; + unawaited(_speech.stop()); + } + } + + /// Consumes a feed snapshot. Stale/offline/calm snapshots stop speech; live + /// duplicates and older serials are ignored. + void update( + RealtimeState> state, { + required String languageTag, + required EewSpeechFormatter format, + }) { + if (!_active) return; + final alerts = state.data; + if (state.status != RealtimeStatus.live || + alerts == null || + alerts.isEmpty) { + if (!_hasCurrentAlert) return; + _hasCurrentAlert = false; + _generation++; + _gate.cancelAnnouncement(); + unawaited(_speech.stop()); + return; + } + + final alert = alerts.first; + final previous = _seenSerials[alert.id]; + if (previous != null && alert.serial <= previous) return; + _seenSerials[alert.id] = alert.serial; + _hasCurrentAlert = true; + + final generation = ++_generation; + final gateGeneration = _gate.beginAnnouncement(); + unawaited( + _announce(alert, generation, gateGeneration, languageTag, format), + ); + } + + Future _announce( + Eew alert, + int generation, + int gateGeneration, + String languageTag, + EewSpeechFormatter format, + ) async { + try { + await _speech.stop(); + final estimate = await _estimate(alert); + if (!_active || generation != _generation) return; + await _speech + .speak(format(estimate), languageTag: languageTag) + .timeout(speechTimeout); + } catch (error, stackTrace) { + // stop() completing the superseded speak future with a non-success result + // is the expected latest-report-wins path, not a TTS engine failure. + if (!_active || generation != _generation) return; + Log.handle(error, stackTrace, 'foreground EEW speech'); + await _speech.stop(); + } finally { + if (_active && generation == _generation) { + await _gate.completeAnnouncement(gateGeneration); + } + } + } + + /// Stops speech and releases any foreground warning retained by the gate. + void dispose() { + _active = false; + _hasCurrentAlert = false; + _generation++; + _gate.setActive(false); + unawaited(_speech.stop()); + } +} diff --git a/lib/features/map/presentation/pages/map_page.dart b/lib/features/map/presentation/pages/map_page.dart index 9f1cbe997..a75ff290b 100644 --- a/lib/features/map/presentation/pages/map_page.dart +++ b/lib/features/map/presentation/pages/map_page.dart @@ -1,7 +1,6 @@ /// Full-screen map tab — assembles overlay layers for [MapScaffold]. library; -import 'package:dpip/core/build/demo_flags.dart'; import 'package:dpip/core/geo/town_directory.dart'; import 'package:dpip/core/realtime/realtime_notifier.dart'; import 'package:dpip/core/settings/default_map_layer.dart'; @@ -129,10 +128,10 @@ class _MapPageState extends State { @override Widget build(BuildContext context) { final visibility = context.watch(); - // In demo mode the monitor is what there is to see — open straight on it. - final preferred = kMonitorDemoEnabled - ? DefaultMapLayer.monitor - : context.watch().layer; + // The monitor demo no longer opens straight onto the monitor: speech and + // its warning sound are scoped to a monitor the user is actually viewing, + // so demo data must not silently change the active layer. + final preferred = context.watch().layer; // Open on the preferred layer unless it (and only it) is hidden; hidden // layers are otherwise offered like any other. final initial = _layers.firstWhere( diff --git a/lib/features/map/presentation/widgets/rts_monitor_panel.dart b/lib/features/map/presentation/widgets/rts_monitor_panel.dart index 0d08fae33..e0a755158 100644 --- a/lib/features/map/presentation/widgets/rts_monitor_panel.dart +++ b/lib/features/map/presentation/widgets/rts_monitor_panel.dart @@ -5,21 +5,32 @@ /// [MapLayer.buildLegend]. library; +import 'dart:async'; + import 'package:dpip/app/theme/app_radius.dart'; import 'package:dpip/app/theme/app_spacing.dart'; +import 'package:dpip/core/build/demo_flags.dart'; import 'package:dpip/core/realtime/app_time.dart'; import 'package:dpip/core/realtime/realtime_notifier.dart'; import 'package:dpip/core/realtime/realtime_state.dart'; +import 'package:dpip/core/geo/location_service.dart'; +import 'package:dpip/core/models/lat_lng.dart'; +import 'package:dpip/core/notifications/notification_service.dart'; +import 'package:dpip/core/speech/speech_service.dart'; import 'package:dpip/features/earthquake/domain/eew.dart'; +import 'package:dpip/features/earthquake/domain/eew_local_estimate.dart'; import 'package:dpip/features/earthquake/domain/rts.dart'; import 'package:dpip/features/map/presentation/pages/map_page.dart'; +import 'package:dpip/features/map/presentation/monitor_eew_announcement_controller.dart'; import 'package:dpip/features/map/presentation/widgets/monitor_eew_card.dart'; import 'package:dpip/l10n/gen/app_localizations.dart'; import 'package:dpip/shared/navigation/refresh_on_appear.dart'; import 'package:dpip/shared/widgets/alert_cycle_chip.dart'; import 'package:dpip/shared/widgets/map_color_legend.dart'; +import 'package:dpip/shared/seismic/spoken_intensity.dart'; import 'package:flutter/material.dart'; import 'package:intl/intl.dart'; +import 'package:provider/provider.dart'; /// The RTS layer's overlay, laid over the full map (via the scaffold's /// `buildSheet` slot): the active EEW alert card above a freshness strip at @@ -54,7 +65,8 @@ class RtsMonitorPanel extends StatefulWidget { State createState() => _RtsMonitorPanelState(); } -class _RtsMonitorPanelState extends State { +class _RtsMonitorPanelState extends State + with WidgetsBindingObserver { /// Whether the map tab is the shell's visible one. The RTS feed keeps /// notifying at ~1 Hz behind other tabs (the polling itself must continue — /// it is a safety feed), but rebuilding a hidden panel for every poll is @@ -62,14 +74,22 @@ class _RtsMonitorPanelState extends State { /// up in one build on return. bool _visible = true; VisibleTab? _visibleTab; + MonitorEewAnnouncementController? _announcement; + AppLocalizations? _l10n; + String _languageTag = 'zh-TW'; + AppLifecycleState? _lifecycleState; + bool _demoWarningSubmitted = false; void _onData() { + _syncAnnouncement(); if (_visible && mounted) setState(() {}); } @override void initState() { super.initState(); + WidgetsBinding.instance.addObserver(this); + _lifecycleState = WidgetsBinding.instance.lifecycleState; widget.feed.addListener(_onData); widget.eew.addListener(_onData); widget.eewIndex.addListener(_onData); @@ -90,33 +110,127 @@ class _RtsMonitorPanelState extends State { oldWidget.eewIndex.removeListener(_onData); widget.eewIndex.addListener(_onData); } + _syncAnnouncement(); } @override void didChangeDependencies() { super.didChangeDependencies(); + _l10n = AppLocalizations.of(context); + _languageTag = Localizations.localeOf(context).toLanguageTag(); + _announcement ??= _createAnnouncementController(); final visibleTab = VisibleTabScope.of(context); - if (identical(visibleTab, _visibleTab)) return; - _visibleTab?.removeListener(_syncVisibility); - _visibleTab = visibleTab; - visibleTab?.addListener(_syncVisibility); - _syncVisibility(); + if (!identical(visibleTab, _visibleTab)) { + _visibleTab?.removeListener(_syncVisibility); + _visibleTab = visibleTab; + visibleTab?.addListener(_syncVisibility); + _syncVisibility(); + } + _syncAnnouncement(); + } + + MonitorEewAnnouncementController? _createAnnouncementController() { + // Nullable reads keep this leaf widget independently testable; the app's + // core provider list always supplies both services. + final speech = context.read(); + final notifications = context.read(); + if (speech == null || notifications == null) return null; + final location = context.read(); + return MonitorEewAnnouncementController( + speech, + notifications.foregroundEewGate, + (alert) async { + // A warning cannot wait on a live GPS timeout. Use the OS's recent + // cached fix; when none is fresh enough, announce the EEW max instead. + final fix = await location.lastKnownFix(); + if (fix == null) { + return (scale: alert.info.max.clamp(0, 9), isLocal: false); + } + final estimate = estimateLocalShaking(alert, LatLng(fix.lat, fix.lng)); + return (scale: estimate.scale, isLocal: true); + }, + ); } void _syncVisibility() { final visible = _visibleTab?.isOnScreen(MapPage.tabIndex) ?? true; if (visible == _visible) return; _visible = visible; + _syncAnnouncement(); // Coming back: one build to catch up on everything missed while hidden. if (visible && mounted) setState(() {}); } + /// Sound must use a stricter visibility check than rendering. This widget + /// can be mounted before the shell installs [VisibleTabScope], and treating + /// that transient state as visible would announce an alert from a map branch + /// the user has not opened yet. + bool get _isMonitorOnScreen => + _visibleTab?.isOnScreen(MapPage.tabIndex) ?? false; + + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + _lifecycleState = state; + _syncAnnouncement(); + } + + void _syncAnnouncement() { + final controller = _announcement; + final l10n = _l10n; + if (controller == null || l10n == null) return; + final foreground = + _lifecycleState == null || _lifecycleState == AppLifecycleState.resumed; + controller.setActive(_isMonitorOnScreen && foreground); + controller.update( + widget.eew.state, + languageTag: _languageTag, + format: (estimate) { + final intensity = spokenIntensityLabel(estimate.scale, _languageTag); + return estimate.isLocal + ? l10n.eewSpokenLocalIntensity(intensity) + : l10n.eewSpokenMaxIntensity(intensity); + }, + ); + _submitDemoWarning(l10n); + } + + void _submitDemoWarning(AppLocalizations l10n) { + final foreground = + _lifecycleState == null || _lifecycleState == AppLifecycleState.resumed; + if (!kMonitorDemoSoundEnabled || + _demoWarningSubmitted || + !_isMonitorOnScreen || + !foreground) { + return; + } + final state = widget.eew.state; + final alerts = state.data; + if (state.status != RealtimeStatus.live || + alerts == null || + alerts.isEmpty) { + return; + } + _demoWarningSubmitted = true; + final intensity = spokenIntensityLabel( + alerts.first.info.max.clamp(0, 9), + _languageTag, + ); + unawaited( + context.read().showDebugEewWarning( + title: l10n.mapLayerMonitor, + body: l10n.eewSpokenMaxIntensity(intensity), + ), + ); + } + @override void dispose() { widget.feed.removeListener(_onData); widget.eew.removeListener(_onData); widget.eewIndex.removeListener(_onData); _visibleTab?.removeListener(_syncVisibility); + WidgetsBinding.instance.removeObserver(this); + _announcement?.dispose(); super.dispose(); } diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index c99e90d94..7117c7add 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -3958,5 +3958,15 @@ "bugTrackerStaff": "Staff", "@bugTrackerStaff": { "description": "Badge beside triage-team names on bug threads" + }, + "eewSpokenLocalIntensity": "Estimated intensity at your location: {intensity}.", + "@eewSpokenLocalIntensity": { + "description": "Short foreground TTS phrase before an EEW warning sound", + "placeholders": {"intensity": {"type": "String"}} + }, + "eewSpokenMaxIntensity": "Estimated maximum intensity: {intensity}.", + "@eewSpokenMaxIntensity": { + "description": "TTS fallback when the device location is unavailable", + "placeholders": {"intensity": {"type": "String"}} } } diff --git a/lib/l10n/app_fil.arb b/lib/l10n/app_fil.arb index 2a1746f16..744f48b69 100644 --- a/lib/l10n/app_fil.arb +++ b/lib/l10n/app_fil.arb @@ -1982,5 +1982,7 @@ "bugTrackerJoinDiscussion": "Makilahok sa talakayan sa Discord", "bugTrackerSortLast": "Pinakabagong aktibidad", "bugTrackerSortMostDiscussed": "Pinakamaraming talakayan", - "bugTrackerStaff": "Kawani" + "bugTrackerStaff": "Kawani", + "eewSpokenLocalIntensity": "Tinatayang intensidad sa iyong lokasyon: {intensity}.", + "eewSpokenMaxIntensity": "Tinatayang pinakamataas na intensidad: {intensity}." } diff --git a/lib/l10n/app_id.arb b/lib/l10n/app_id.arb index 689b14fa2..38c8355c2 100644 --- a/lib/l10n/app_id.arb +++ b/lib/l10n/app_id.arb @@ -1982,5 +1982,7 @@ "bugTrackerJoinDiscussion": "Ikuti diskusi di Discord", "bugTrackerSortLast": "Aktivitas terbaru", "bugTrackerSortMostDiscussed": "Paling banyak dibahas", - "bugTrackerStaff": "Staf" + "bugTrackerStaff": "Staf", + "eewSpokenLocalIntensity": "Perkiraan intensitas di lokasi Anda: {intensity}.", + "eewSpokenMaxIntensity": "Perkiraan intensitas maksimum: {intensity}." } diff --git a/lib/l10n/app_ja.arb b/lib/l10n/app_ja.arb index 2f939c045..d8b1c6bb7 100644 --- a/lib/l10n/app_ja.arb +++ b/lib/l10n/app_ja.arb @@ -1982,5 +1982,7 @@ "bugTrackerJoinDiscussion": "Discord で議論に参加する", "bugTrackerSortLast": "最新の返信", "bugTrackerSortMostDiscussed": "返信が多い順", - "bugTrackerStaff": "スタッフ" + "bugTrackerStaff": "スタッフ", + "eewSpokenLocalIntensity": "現在地の予想震度、{intensity}。", + "eewSpokenMaxIntensity": "予想最大震度、{intensity}。" } diff --git a/lib/l10n/app_ko.arb b/lib/l10n/app_ko.arb index 3a6f36861..41640e4f4 100644 --- a/lib/l10n/app_ko.arb +++ b/lib/l10n/app_ko.arb @@ -1982,5 +1982,7 @@ "bugTrackerJoinDiscussion": "Discord에서 논의에 참여하기", "bugTrackerSortLast": "최근 활동", "bugTrackerSortMostDiscussed": "답글 많은 순", - "bugTrackerStaff": "스태프" + "bugTrackerStaff": "스태프", + "eewSpokenLocalIntensity": "현재 위치 예상 진도, {intensity}.", + "eewSpokenMaxIntensity": "예상 최대 진도, {intensity}." } diff --git a/lib/l10n/app_th.arb b/lib/l10n/app_th.arb index 4cabac60c..01e8b4461 100644 --- a/lib/l10n/app_th.arb +++ b/lib/l10n/app_th.arb @@ -1982,5 +1982,7 @@ "bugTrackerJoinDiscussion": "ร่วมพูดคุยที่ Discord", "bugTrackerSortLast": "ล่าสุด", "bugTrackerSortMostDiscussed": "พูดคุยมากที่สุด", - "bugTrackerStaff": "ทีมงาน" + "bugTrackerStaff": "ทีมงาน", + "eewSpokenLocalIntensity": "คาดการณ์ความรุนแรง ณ ตำแหน่งของคุณ: {intensity}", + "eewSpokenMaxIntensity": "คาดการณ์ความรุนแรงสูงสุด: {intensity}" } diff --git a/lib/l10n/app_vi.arb b/lib/l10n/app_vi.arb index 585fd637b..06c96ae23 100644 --- a/lib/l10n/app_vi.arb +++ b/lib/l10n/app_vi.arb @@ -1982,5 +1982,7 @@ "bugTrackerJoinDiscussion": "Tham gia thảo luận trên Discord", "bugTrackerSortLast": "Hoạt động mới nhất", "bugTrackerSortMostDiscussed": "Nhiều thảo luận nhất", - "bugTrackerStaff": "Nhân sự" + "bugTrackerStaff": "Nhân sự", + "eewSpokenLocalIntensity": "Cường độ dự kiến tại vị trí của bạn: {intensity}.", + "eewSpokenMaxIntensity": "Cường độ tối đa dự kiến: {intensity}." } diff --git a/lib/l10n/app_yue.arb b/lib/l10n/app_yue.arb index 23614e1a5..c9bb04df7 100644 --- a/lib/l10n/app_yue.arb +++ b/lib/l10n/app_yue.arb @@ -1982,5 +1982,7 @@ "bugTrackerJoinDiscussion": "去 Discord 一齊傾", "bugTrackerSortLast": "最後傾偈", "bugTrackerSortMostDiscussed": "最多討論", - "bugTrackerStaff": "工作人員" + "bugTrackerStaff": "工作人員", + "eewSpokenLocalIntensity": "所在地預估震度,{intensity}。", + "eewSpokenMaxIntensity": "預估最大震度,{intensity}。" } diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index d8cfad62d..95838e573 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -1974,5 +1974,7 @@ "bugTrackerJoinDiscussion": "至 Discord 参与讨论", "bugTrackerSortLast": "最后讨论", "bugTrackerSortMostDiscussed": "最多讨论", - "bugTrackerStaff": "工作人员" + "bugTrackerStaff": "工作人员", + "eewSpokenLocalIntensity": "所在地預估震度,{intensity}。", + "eewSpokenMaxIntensity": "預估最大震度,{intensity}。" } diff --git a/lib/l10n/app_zh_Hans.arb b/lib/l10n/app_zh_Hans.arb index 82b149b76..545eeedd5 100644 --- a/lib/l10n/app_zh_Hans.arb +++ b/lib/l10n/app_zh_Hans.arb @@ -1982,5 +1982,7 @@ "bugTrackerJoinDiscussion": "至 Discord 参与讨论", "bugTrackerSortLast": "最后讨论", "bugTrackerSortMostDiscussed": "最多讨论", - "bugTrackerStaff": "工作人员" + "bugTrackerStaff": "工作人员", + "eewSpokenLocalIntensity": "所在地预估烈度,{intensity}。", + "eewSpokenMaxIntensity": "预估最大烈度,{intensity}。" } diff --git a/lib/l10n/app_zh_Hant_HK.arb b/lib/l10n/app_zh_Hant_HK.arb index 397163df6..4df46480e 100644 --- a/lib/l10n/app_zh_Hant_HK.arb +++ b/lib/l10n/app_zh_Hant_HK.arb @@ -1982,5 +1982,7 @@ "bugTrackerJoinDiscussion": "至 Discord 參與討論", "bugTrackerSortLast": "最後討論", "bugTrackerSortMostDiscussed": "最多討論", - "bugTrackerStaff": "工作人員" + "bugTrackerStaff": "工作人員", + "eewSpokenLocalIntensity": "所在地預估震度,{intensity}。", + "eewSpokenMaxIntensity": "預估最大震度,{intensity}。" } diff --git a/lib/l10n/app_zh_TW.arb b/lib/l10n/app_zh_TW.arb index 29c787c8c..d62a70b39 100644 --- a/lib/l10n/app_zh_TW.arb +++ b/lib/l10n/app_zh_TW.arb @@ -1982,5 +1982,7 @@ "bugTrackerJoinDiscussion": "至 Discord 參與討論", "bugTrackerSortLast": "最後討論", "bugTrackerSortMostDiscussed": "最多討論", - "bugTrackerStaff": "工作人員" + "bugTrackerStaff": "工作人員", + "eewSpokenLocalIntensity": "所在地預估震度,{intensity}。", + "eewSpokenMaxIntensity": "預估最大震度,{intensity}。" } diff --git a/lib/l10n/gen/app_localizations.dart b/lib/l10n/gen/app_localizations.dart index 3d3cf2cbb..5b09af43a 100644 --- a/lib/l10n/gen/app_localizations.dart +++ b/lib/l10n/gen/app_localizations.dart @@ -6274,6 +6274,18 @@ abstract class AppLocalizations { /// In en, this message translates to: /// **'Staff'** String get bugTrackerStaff; + + /// Short foreground TTS phrase before an EEW warning sound + /// + /// In en, this message translates to: + /// **'Estimated intensity at your location: {intensity}.'** + String eewSpokenLocalIntensity(String intensity); + + /// TTS fallback when the device location is unavailable + /// + /// In en, this message translates to: + /// **'Estimated maximum intensity: {intensity}.'** + String eewSpokenMaxIntensity(String intensity); } class _AppLocalizationsDelegate diff --git a/lib/l10n/gen/app_localizations_en.dart b/lib/l10n/gen/app_localizations_en.dart index 074c99832..3bb99d1d2 100644 --- a/lib/l10n/gen/app_localizations_en.dart +++ b/lib/l10n/gen/app_localizations_en.dart @@ -3300,4 +3300,14 @@ class AppLocalizationsEn extends AppLocalizations { @override String get bugTrackerStaff => 'Staff'; + + @override + String eewSpokenLocalIntensity(String intensity) { + return 'Estimated intensity at your location: $intensity.'; + } + + @override + String eewSpokenMaxIntensity(String intensity) { + return 'Estimated maximum intensity: $intensity.'; + } } diff --git a/lib/l10n/gen/app_localizations_fil.dart b/lib/l10n/gen/app_localizations_fil.dart index 580efa4bb..296649036 100644 --- a/lib/l10n/gen/app_localizations_fil.dart +++ b/lib/l10n/gen/app_localizations_fil.dart @@ -3318,4 +3318,14 @@ class AppLocalizationsFil extends AppLocalizations { @override String get bugTrackerStaff => 'Kawani'; + + @override + String eewSpokenLocalIntensity(String intensity) { + return 'Tinatayang intensidad sa iyong lokasyon: $intensity.'; + } + + @override + String eewSpokenMaxIntensity(String intensity) { + return 'Tinatayang pinakamataas na intensidad: $intensity.'; + } } diff --git a/lib/l10n/gen/app_localizations_id.dart b/lib/l10n/gen/app_localizations_id.dart index 0cb43d608..b4b6392c0 100644 --- a/lib/l10n/gen/app_localizations_id.dart +++ b/lib/l10n/gen/app_localizations_id.dart @@ -3311,4 +3311,14 @@ class AppLocalizationsId extends AppLocalizations { @override String get bugTrackerStaff => 'Staf'; + + @override + String eewSpokenLocalIntensity(String intensity) { + return 'Perkiraan intensitas di lokasi Anda: $intensity.'; + } + + @override + String eewSpokenMaxIntensity(String intensity) { + return 'Perkiraan intensitas maksimum: $intensity.'; + } } diff --git a/lib/l10n/gen/app_localizations_ja.dart b/lib/l10n/gen/app_localizations_ja.dart index f7f7124dd..89dddb373 100644 --- a/lib/l10n/gen/app_localizations_ja.dart +++ b/lib/l10n/gen/app_localizations_ja.dart @@ -3239,4 +3239,14 @@ class AppLocalizationsJa extends AppLocalizations { @override String get bugTrackerStaff => 'スタッフ'; + + @override + String eewSpokenLocalIntensity(String intensity) { + return '現在地の予想震度、$intensity。'; + } + + @override + String eewSpokenMaxIntensity(String intensity) { + return '予想最大震度、$intensity。'; + } } diff --git a/lib/l10n/gen/app_localizations_ko.dart b/lib/l10n/gen/app_localizations_ko.dart index 1ed48100d..e2502f19c 100644 --- a/lib/l10n/gen/app_localizations_ko.dart +++ b/lib/l10n/gen/app_localizations_ko.dart @@ -3239,4 +3239,14 @@ class AppLocalizationsKo extends AppLocalizations { @override String get bugTrackerStaff => '스태프'; + + @override + String eewSpokenLocalIntensity(String intensity) { + return '현재 위치 예상 진도, $intensity.'; + } + + @override + String eewSpokenMaxIntensity(String intensity) { + return '예상 최대 진도, $intensity.'; + } } diff --git a/lib/l10n/gen/app_localizations_th.dart b/lib/l10n/gen/app_localizations_th.dart index 48c1bb12f..d476aeb17 100644 --- a/lib/l10n/gen/app_localizations_th.dart +++ b/lib/l10n/gen/app_localizations_th.dart @@ -3293,4 +3293,14 @@ class AppLocalizationsTh extends AppLocalizations { @override String get bugTrackerStaff => 'ทีมงาน'; + + @override + String eewSpokenLocalIntensity(String intensity) { + return 'คาดการณ์ความรุนแรง ณ ตำแหน่งของคุณ: $intensity'; + } + + @override + String eewSpokenMaxIntensity(String intensity) { + return 'คาดการณ์ความรุนแรงสูงสุด: $intensity'; + } } diff --git a/lib/l10n/gen/app_localizations_vi.dart b/lib/l10n/gen/app_localizations_vi.dart index 9a1cb1b7a..bd731d071 100644 --- a/lib/l10n/gen/app_localizations_vi.dart +++ b/lib/l10n/gen/app_localizations_vi.dart @@ -3301,4 +3301,14 @@ class AppLocalizationsVi extends AppLocalizations { @override String get bugTrackerStaff => 'Nhân sự'; + + @override + String eewSpokenLocalIntensity(String intensity) { + return 'Cường độ dự kiến tại vị trí của bạn: $intensity.'; + } + + @override + String eewSpokenMaxIntensity(String intensity) { + return 'Cường độ tối đa dự kiến: $intensity.'; + } } diff --git a/lib/l10n/gen/app_localizations_yue.dart b/lib/l10n/gen/app_localizations_yue.dart index df9333e1a..089cd25c8 100644 --- a/lib/l10n/gen/app_localizations_yue.dart +++ b/lib/l10n/gen/app_localizations_yue.dart @@ -3222,4 +3222,14 @@ class AppLocalizationsYue extends AppLocalizations { @override String get bugTrackerStaff => '工作人員'; + + @override + String eewSpokenLocalIntensity(String intensity) { + return '所在地預估震度,$intensity。'; + } + + @override + String eewSpokenMaxIntensity(String intensity) { + return '預估最大震度,$intensity。'; + } } diff --git a/lib/l10n/gen/app_localizations_zh.dart b/lib/l10n/gen/app_localizations_zh.dart index 1d6e62400..c85533754 100644 --- a/lib/l10n/gen/app_localizations_zh.dart +++ b/lib/l10n/gen/app_localizations_zh.dart @@ -3222,6 +3222,16 @@ class AppLocalizationsZh extends AppLocalizations { @override String get bugTrackerStaff => '工作人员'; + + @override + String eewSpokenLocalIntensity(String intensity) { + return '所在地預估震度,$intensity。'; + } + + @override + String eewSpokenMaxIntensity(String intensity) { + return '預估最大震度,$intensity。'; + } } /// The translations for Chinese, using the Han script (`zh_Hans`). @@ -6441,6 +6451,16 @@ class AppLocalizationsZhHans extends AppLocalizationsZh { @override String get bugTrackerStaff => '工作人员'; + + @override + String eewSpokenLocalIntensity(String intensity) { + return '所在地预估烈度,$intensity。'; + } + + @override + String eewSpokenMaxIntensity(String intensity) { + return '预估最大烈度,$intensity。'; + } } /// The translations for Chinese, as used in Hong Kong, using the Han script (`zh_Hant_HK`). @@ -9660,6 +9680,16 @@ class AppLocalizationsZhHantHk extends AppLocalizationsZh { @override String get bugTrackerStaff => '工作人員'; + + @override + String eewSpokenLocalIntensity(String intensity) { + return '所在地預估震度,$intensity。'; + } + + @override + String eewSpokenMaxIntensity(String intensity) { + return '預估最大震度,$intensity。'; + } } /// The translations for Chinese, as used in Taiwan (`zh_TW`). @@ -12879,4 +12909,14 @@ class AppLocalizationsZhTw extends AppLocalizationsZh { @override String get bugTrackerStaff => '工作人員'; + + @override + String eewSpokenLocalIntensity(String intensity) { + return '所在地預估震度,$intensity。'; + } + + @override + String eewSpokenMaxIntensity(String intensity) { + return '預估最大震度,$intensity。'; + } } diff --git a/lib/shared/seismic/spoken_intensity.dart b/lib/shared/seismic/spoken_intensity.dart new file mode 100644 index 000000000..b508e55b6 --- /dev/null +++ b/lib/shared/seismic/spoken_intensity.dart @@ -0,0 +1,47 @@ +/// Locale-aware words for speaking Taiwan's ten-step intensity scale. +library; + +/// Returns a TTS-friendly label for a discrete CWA intensity [scale]. +/// +/// Symbols such as `5⁻` are intentionally avoided: platform speech engines +/// pronounce superscript signs inconsistently. Chinese, Japanese, and Korean +/// get their conventional weak/strong words; the Chinese split levels keep a +/// trailing `等級` because Google zh-TW can swallow a sentence-final `強` even +/// though it reports the utterance as completed. Other locales get unambiguous +/// English words inside their localized sentence. +String spokenIntensityLabel(int scale, String languageTag) { + final level = scale.clamp(0, 9); + final language = languageTag.toLowerCase(); + if (language.startsWith('zh')) { + return const [ + '零級', + '一級', + '二級', + '三級', + '四級', + '五弱等級', + '五強等級', + '六弱等級', + '六強等級', + '七級', + ][level]; + } + if (language.startsWith('ja')) { + return const ['0', '1', '2', '3', '4', '5弱', '5強', '6弱', '6強', '7'][level]; + } + if (language.startsWith('ko')) { + return const ['0', '1', '2', '3', '4', '5약', '5강', '6약', '6강', '7'][level]; + } + return const [ + 'zero', + 'one', + 'two', + 'three', + 'four', + 'five lower', + 'five upper', + 'six lower', + 'six upper', + 'seven', + ][level]; +} diff --git a/test/core/notifications/foreground_eew_announcement_gate_test.dart b/test/core/notifications/foreground_eew_announcement_gate_test.dart new file mode 100644 index 000000000..d1615e291 --- /dev/null +++ b/test/core/notifications/foreground_eew_announcement_gate_test.dart @@ -0,0 +1,76 @@ +/// Tests foreground EEW notification sequencing and its safety fallback. +library; + +import 'package:dpip/core/notifications/foreground_eew_announcement_gate.dart'; +import 'package:fake_async/fake_async.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + test( + 'holds only the newest notification until latest speech completes', + () async { + final gate = ForegroundEewAnnouncementGate(); + var displayed = []; + gate.setActive(true); + final first = gate.beginAnnouncement(); + + await gate.submit(() async => displayed.add('first')); + final second = gate.beginAnnouncement(); + await gate.submit(() async => displayed.add('second')); + + await gate.completeAnnouncement(first); + expect( + displayed, + isEmpty, + reason: 'obsolete speech cannot release sound', + ); + await gate.completeAnnouncement(second); + expect(displayed, ['second']); + }, + ); + + test('inactive gate displays immediately', () async { + final gate = ForegroundEewAnnouncementGate(); + var displayed = false; + + await gate.submit(() async => displayed = true); + + expect(displayed, isTrue); + }); + + test('default fallback does not overlap the eight-second speech budget', () { + fakeAsync((async) { + final gate = ForegroundEewAnnouncementGate(); + var displayed = false; + gate.setActive(true); + gate.beginAnnouncement(); + gate.submit(() async => displayed = true); + + async.elapse(const Duration(seconds: 8)); + async.flushMicrotasks(); + expect(displayed, isFalse); + + async.elapse(const Duration(seconds: 2)); + async.flushMicrotasks(); + expect(displayed, isTrue); + }); + }); + + test('timeout releases a warning when speech never completes', () { + fakeAsync((async) { + final gate = ForegroundEewAnnouncementGate( + maxHold: const Duration(seconds: 2), + ); + var displayed = false; + gate.setActive(true); + gate.beginAnnouncement(); + gate.submit(() async => displayed = true); + + async.elapse(const Duration(seconds: 1)); + expect(displayed, isFalse); + async.elapse(const Duration(seconds: 1)); + async.flushMicrotasks(); + expect(displayed, isTrue); + }); + }); +} diff --git a/test/features/map/presentation/monitor_eew_announcement_controller_test.dart b/test/features/map/presentation/monitor_eew_announcement_controller_test.dart new file mode 100644 index 000000000..5208019e3 --- /dev/null +++ b/test/features/map/presentation/monitor_eew_announcement_controller_test.dart @@ -0,0 +1,176 @@ +/// Tests latest-report-wins EEW speech on the visible seismic monitor. +library; + +import 'dart:async'; + +import 'package:dpip/core/notifications/foreground_eew_announcement_gate.dart'; +import 'package:dpip/core/realtime/realtime_state.dart'; +import 'package:dpip/core/speech/speech_service.dart'; +import 'package:dpip/features/earthquake/domain/eew.dart'; +import 'package:dpip/features/map/presentation/monitor_eew_announcement_controller.dart'; +import 'package:flutter_test/flutter_test.dart'; + +class _FakeSpeech implements SpeechService { + final List spoken = []; + final List> completions = []; + int stops = 0; + + @override + Future speak(String text, {required String languageTag}) { + spoken.add('$languageTag:$text'); + final completion = Completer(); + completions.add(completion); + return completion.future; + } + + @override + Future stop() async => stops++; + + @override + void dispose() {} +} + +Eew _alert(int serial) => Eew( + agency: 'CWA', + id: 'event', + serial: serial, + status: 0, + isFinal: false, + info: const EewInfo( + time: 0, + longitude: 121, + latitude: 23, + depth: 10, + magnitude: 6, + location: 'test', + max: 6, + ), +); + +RealtimeState> _live(Eew alert) => + RealtimeState(status: RealtimeStatus.live, data: [alert]); + +Future _flush() async { + await Future.delayed(Duration.zero); + await Future.delayed(Duration.zero); +} + +void main() { + test( + 'new serial interrupts old speech and only latest releases sound', + () async { + final speech = _FakeSpeech(); + final gate = ForegroundEewAnnouncementGate(); + final controller = MonitorEewAnnouncementController( + speech, + gate, + (alert) async => (scale: alert.serial, isLocal: true), + ); + controller.setActive(true); + controller.update( + _live(_alert(1)), + languageTag: 'zh-TW', + format: (estimate) => '震度${estimate.scale}', + ); + await _flush(); + expect(speech.spoken, ['zh-TW:震度1']); + + var notifications = 0; + await gate.submit(() async => notifications++); + controller.update( + _live(_alert(2)), + languageTag: 'zh-TW', + format: (estimate) => '震度${estimate.scale}', + ); + await _flush(); + expect(speech.spoken, ['zh-TW:震度1', 'zh-TW:震度2']); + expect(speech.stops, greaterThanOrEqualTo(2)); + + speech.completions.first.complete(); + await _flush(); + expect(notifications, 0); + + speech.completions.last.complete(); + await _flush(); + expect(notifications, 1); + controller.dispose(); + }, + ); + + test('duplicate and older serials are not spoken again', () async { + final speech = _FakeSpeech(); + final controller = MonitorEewAnnouncementController( + speech, + ForegroundEewAnnouncementGate(), + (_) async => (scale: 4, isLocal: true), + ); + controller.setActive(true); + for (final serial in [2, 2, 1]) { + controller.update( + _live(_alert(serial)), + languageTag: 'zh-TW', + format: (_) => '所在地預估震度,四級。', + ); + } + await _flush(); + + expect(speech.spoken, hasLength(1)); + speech.completions.single.complete(); + controller.dispose(); + }); + + test('stale feed stops speech and releases the pending warning', () async { + final speech = _FakeSpeech(); + final gate = ForegroundEewAnnouncementGate(); + final controller = MonitorEewAnnouncementController( + speech, + gate, + (_) async => (scale: 4, isLocal: true), + ); + controller.setActive(true); + controller.update( + _live(_alert(1)), + languageTag: 'zh-TW', + format: (_) => '所在地預估震度,四級。', + ); + await _flush(); + var displayed = false; + await gate.submit(() async => displayed = true); + + controller.update( + RealtimeState>(status: RealtimeStatus.stale, data: [_alert(1)]), + languageTag: 'zh-TW', + format: (_) => 'unused', + ); + await _flush(); + + expect(displayed, isTrue); + expect(speech.stops, greaterThanOrEqualTo(2)); + controller.dispose(); + }); + + test( + 'repeated calm feed ticks do not call the platform every second', + () async { + final speech = _FakeSpeech(); + final controller = MonitorEewAnnouncementController( + speech, + ForegroundEewAnnouncementGate(), + (_) async => (scale: 4, isLocal: true), + ); + controller.setActive(true); + const calm = RealtimeState>( + status: RealtimeStatus.live, + data: [], + ); + + for (var i = 0; i < 3; i++) { + controller.update(calm, languageTag: 'zh-TW', format: (_) => 'unused'); + } + await _flush(); + + expect(speech.stops, 0); + controller.dispose(); + }, + ); +} diff --git a/test/shared/seismic/spoken_intensity_test.dart b/test/shared/seismic/spoken_intensity_test.dart new file mode 100644 index 000000000..659e2dd52 --- /dev/null +++ b/test/shared/seismic/spoken_intensity_test.dart @@ -0,0 +1,19 @@ +/// Tests speech-safe labels for the split CWA intensity scale. +library; + +import 'package:dpip/shared/seismic/spoken_intensity.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + test('Traditional Chinese speaks weak and strong words', () { + expect(spokenIntensityLabel(5, 'zh-TW'), '五弱等級'); + expect(spokenIntensityLabel(6, 'zh-TW'), '五強等級'); + expect(spokenIntensityLabel(7, 'zh-TW'), '六弱等級'); + expect(spokenIntensityLabel(8, 'zh-TW'), '六強等級'); + }); + + test('out-of-range values are clamped', () { + expect(spokenIntensityLabel(-1, 'en'), 'zero'); + expect(spokenIntensityLabel(10, 'en'), 'seven'); + }); +}