Skip to content
Open
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
3 changes: 3 additions & 0 deletions android/app/src/main/kotlin/com/exptech/dpip/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -62,5 +62,8 @@ class MainActivity : FlutterActivity() {

MethodChannel(messenger, PlainChannelsChannel.NAME)
.setMethodCallHandler(PlainChannelsChannel(applicationContext))

MethodChannel(messenger, SpeechChannel.NAME)
.setMethodCallHandler(SpeechChannel(applicationContext))
}
}
173 changes: 173 additions & 0 deletions android/app/src/main/kotlin/com/exptech/dpip/SpeechChannel.kt
Original file line number Diff line number Diff line change
@@ -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<String>("text")
val language = call.argument<String>("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)
}
}
4 changes: 4 additions & 0 deletions ios/Runner.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -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 */; };
Expand Down Expand Up @@ -97,6 +98,7 @@
AA0000000000000000000E01 /* ScreenWakePlugin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScreenWakePlugin.swift; sourceTree = "<group>"; };
AA0000000000000000000G01 /* LocationTrackStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocationTrackStore.swift; sourceTree = "<group>"; };
AA0000000000000000000F01 /* ApnsTokenPlugin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ApnsTokenPlugin.swift; sourceTree = "<group>"; };
AA0000000000000000001001 /* SpeechPlugin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpeechPlugin.swift; sourceTree = "<group>"; };
AA0000000000000000000D01 /* DeviceInfoPlugin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceInfoPlugin.swift; sourceTree = "<group>"; };
B916667D1B2356583B174E80 /* normal.aiff */ = {isa = PBXFileReference; includeInIndex = 1; path = Sounds/normal.aiff; sourceTree = "<group>"; };
CAC4EF00000000000000B002 /* MapCachePlugin.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = MapCachePlugin.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -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 */,
Expand Down Expand Up @@ -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 */,
Expand Down
1 change: 1 addition & 0 deletions ios/Runner/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
108 changes: 108 additions & 0 deletions ios/Runner/SpeechPlugin.swift
Original file line number Diff line number Diff line change
@@ -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()
}
}
Loading
Loading