feat: Android Oboe voice backend — WebRTC APM, VAD, HW/SW toggle, BBCode welcome, link trust, foreground task
Audio engine (Rust): - Android Oboe: WebRTC APM (AEC/NS/AGC/HPF) + TEN/Silero ONNX VAD - Hardware effects (JNI) with software fallback per-effect - Render reference buffer for AEC between output/capture callbacks - Voice activity gate: suppress transmission when speaker muted (all platforms) - Audio focus (SDD-109) + Bluetooth SCO (SDD-110) via JNI - ONNX Runtime 1.26 via ort 2.0.0-rc.12 (down from rc.10, ndarray 0.17) - VAD worker channel capacity 8→32, initial seq u64::MAX (warm-up fix) - TEN VAD default backend (was Silero) - Platform→WebrtcApm resolution after hardware binding - oboe-rs edisonjwa fork with get_raw_session_id() Android Kotlin: - AndroidAudioFocusController + AndroidBluetoothScoController - AndroidAudioLifecycleController (route changes to Flutter) - ProGuard rules for new controllers Flutter UI: - VoiceSettings: Android HW/SW toggle (Platform auto / WebRTC APM) - VoiceStatusChip: mute warning border + Speaker muted label - BBCode welcome message parser (BbCodeText, case-insensitive) - Welcome message foldable (expanded by default) - Link trust dialog (domain wildcards, SharedPreferences) - HapticFeedback on voice sheet opener - Server name in AppBar, version v0.1.0 - Default channel (id=1) visible, serverquery clients hidden - flutter_foreground_task integration Config: - ort load-dynamic on all non-iOS (Android/Linux/Windows) - ONNX Runtime AAR 1.26.0 - ndarray moved to common deps (was Apple-only)
This commit is contained in:
@@ -77,7 +77,7 @@ android {
|
||||
// ANDROID_PLATFORM as -D variables to the child cmake
|
||||
// invocation). See Cargo.toml [patch.crates-io] block and
|
||||
// docs/governance/product-decision-register.md DEC-032.
|
||||
abiFilters += listOf("arm64-v8a", "armeabi-v7a", "x86_64")
|
||||
abiFilters += listOf("arm64-v8a", "x86_64")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -154,6 +154,14 @@ android {
|
||||
}
|
||||
}
|
||||
|
||||
// ONNX Runtime native library for Silero / TEN VAD.
|
||||
// The ort crate (Rust) loads libonnxruntime.so via dlopen at runtime
|
||||
// (`load-dynamic` feature). The AAR ships the .so for arm64-v8a,
|
||||
// armeabi-v7a, x86_64, x86. AGP merges these into the APK/AAB.
|
||||
dependencies {
|
||||
implementation("com.microsoft.onnxruntime:onnxruntime-android:1.26.0")
|
||||
}
|
||||
|
||||
flutter {
|
||||
source = "../.."
|
||||
}
|
||||
|
||||
@@ -25,6 +25,11 @@
|
||||
-keep class app.chanora.chanora_flutter.AndroidVoiceForegroundService { *; }
|
||||
-keep class app.chanora.chanora_flutter.AndroidPermissionRequester { *; }
|
||||
-keep class app.chanora.chanora_flutter.BackIntentBridge { *; }
|
||||
# SDD-109 / SDD-110 / SDD-111: JNI-referenced voice controllers.
|
||||
# Called from the Rust audio engine via JNI static methods.
|
||||
-keep class app.chanora.chanora_flutter.AndroidAudioFocusController { *; }
|
||||
-keep class app.chanora.chanora_flutter.AndroidBluetoothScoController { *; }
|
||||
-keep class app.chanora.chanora_flutter.AndroidAudioLifecycleController { *; }
|
||||
-keep class io.flutter.plugins.** { *; }
|
||||
|
||||
# flutter_rust_bridge generated bindings (SDD-079 TypedBridgeFacade) — keep
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
foregroundServiceType="microphone". Declared here for the
|
||||
entire API ladder; the platform ignores it on older releases. -->
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
|
||||
|
||||
<!-- SDD-trace: SDD-107 AndroidVoiceForegroundService (item 6).
|
||||
Required on API 33+ to display the ongoing voice-session
|
||||
@@ -34,6 +35,17 @@
|
||||
related in-call audio routing operations. -->
|
||||
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
|
||||
|
||||
<!-- SDD-trace: SDD-110 AndroidBluetoothScoController.
|
||||
BLUETOOTH_ADMIN required for startBluetoothSco() / stopBluetoothSco()
|
||||
on API 23-30. Superseded by BLUETOOTH_CONNECT on API 31+. -->
|
||||
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN"
|
||||
android:maxSdkVersion="30" />
|
||||
|
||||
<!-- SDD-trace: SDD-110 AndroidBluetoothScoController.
|
||||
BLUETOOTH required to query BluetoothAdapter on API < 31. -->
|
||||
<uses-permission android:name="android.permission.BLUETOOTH"
|
||||
android:maxSdkVersion="30" />
|
||||
|
||||
<!-- SDD-trace: Android Oboe voice backend; supports SCO/BLE
|
||||
headset routing on API 31+. Declared here so the platform
|
||||
allows querying / connecting to bonded Bluetooth audio devices
|
||||
@@ -80,6 +92,13 @@
|
||||
android:exported="false"
|
||||
android:foregroundServiceType="microphone" />
|
||||
|
||||
<!-- flutter_foreground_task: keeps the Flutter engine alive when
|
||||
connected to a server so voice chat is not killed in background. -->
|
||||
<service
|
||||
android:name="com.pravera.flutter_foreground_task.service.ForegroundService"
|
||||
android:exported="false"
|
||||
android:foregroundServiceType="dataSync" />
|
||||
|
||||
<!-- Don't delete the meta-data below.
|
||||
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
|
||||
<meta-data
|
||||
|
||||
+162
@@ -0,0 +1,162 @@
|
||||
package app.chanora.chanora_flutter
|
||||
|
||||
import android.content.Context
|
||||
import android.media.AudioAttributes
|
||||
import android.media.AudioFocusRequest
|
||||
import android.media.AudioManager
|
||||
import android.os.Build
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.util.Log
|
||||
|
||||
/**
|
||||
* Manages Android audio focus (requestAudioFocus / abandonAudioFocus) for
|
||||
* the Chanora voice session.
|
||||
*
|
||||
* Trace: SDD-109 (Android Audio Focus)
|
||||
*
|
||||
* ## Lifecycle
|
||||
*
|
||||
* 1. [start] — called by the Rust audio engine (via JNI) after the Oboe
|
||||
* voice streams are opened. Requests `AUDIOFOCUS_GAIN` for
|
||||
* `USAGE_VOICE_COMMUNICATION` / `CONTENT_TYPE_SPEECH` and registers
|
||||
* an `OnAudioFocusChangeListener`.
|
||||
* 2. Focus-change callbacks are forwarded to the Rust engine via
|
||||
* `publishFocusChange(int)`, a JNI function declared in
|
||||
* `crates/chanora_audio/src/android_voice_unit.rs`.
|
||||
* 3. [stop] — called by the Rust engine on voice stop. Abandons focus
|
||||
* and clears the listener.
|
||||
*
|
||||
* ## Thread model
|
||||
*
|
||||
* `start` / `stop` are called from a tokio worker thread (via JNI), not
|
||||
* the Android main thread. The `AudioManager` API is thread-safe.
|
||||
* `OnAudioFocusChangeListener` callbacks arrive on the main thread; we
|
||||
* forward to Rust via JNI which attaches the calling thread to the JVM.
|
||||
*/
|
||||
internal class AndroidAudioFocusController {
|
||||
|
||||
companion object {
|
||||
private const val TAG = "ChanoraAudioFocus"
|
||||
|
||||
/**
|
||||
* JNI entry point implemented in
|
||||
* `crates/chanora_audio/src/android_voice_unit.rs`.
|
||||
*
|
||||
* Kotlin calls this from [OnAudioFocusChangeListener] to forward
|
||||
* the focus-change integer to the Rust engine's BackendEvent channel.
|
||||
*/
|
||||
@JvmStatic
|
||||
external fun publishFocusChange(state: Int)
|
||||
|
||||
/**
|
||||
* Start audio focus management.
|
||||
*
|
||||
* Called from Rust via JNI after voice unit start.
|
||||
* Idempotent: repeated calls against an already-started instance
|
||||
* are silently ignored.
|
||||
*/
|
||||
@JvmStatic
|
||||
fun start(context: Context) {
|
||||
if (focusRequested) {
|
||||
Log.d(TAG, "start() called but focus already held; no-op")
|
||||
return
|
||||
}
|
||||
requestFocus(context)
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop audio focus management.
|
||||
*
|
||||
* Called from Rust via JNI on voice stop.
|
||||
* Idempotent: safe to call when no focus is held.
|
||||
*/
|
||||
@JvmStatic
|
||||
fun stop(context: Context) {
|
||||
if (!focusRequested) {
|
||||
Log.d(TAG, "stop() called but no focus held; no-op")
|
||||
return
|
||||
}
|
||||
abandonFocus(context)
|
||||
}
|
||||
|
||||
private var focusRequested: Boolean = false
|
||||
private var focusRequestHandle: AudioFocusRequest? = null
|
||||
|
||||
private val mainHandler = Handler(Looper.getMainLooper())
|
||||
|
||||
private val audioFocusListener = AudioManager.OnAudioFocusChangeListener { focusChange ->
|
||||
Log.i(TAG, "onAudioFocusChange: $focusChange")
|
||||
try {
|
||||
publishFocusChange(focusChange)
|
||||
} catch (t: Throwable) {
|
||||
Log.w(TAG, "publishFocusChange JNI failed: ${t.message}", t)
|
||||
}
|
||||
}
|
||||
|
||||
private fun requestFocus(context: Context) {
|
||||
val am = context.getSystemService(Context.AUDIO_SERVICE) as? AudioManager
|
||||
if (am == null) {
|
||||
Log.e(TAG, "AudioManager unavailable; cannot request audio focus")
|
||||
return
|
||||
}
|
||||
try {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
val attr = AudioAttributes.Builder()
|
||||
.setUsage(AudioAttributes.USAGE_VOICE_COMMUNICATION)
|
||||
.setContentType(AudioAttributes.CONTENT_TYPE_SPEECH)
|
||||
.build()
|
||||
val request = AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN)
|
||||
.setAudioAttributes(attr)
|
||||
.setOnAudioFocusChangeListener(audioFocusListener, mainHandler)
|
||||
.build()
|
||||
val result = am.requestAudioFocus(request)
|
||||
focusRequested = result == AudioManager.AUDIOFOCUS_REQUEST_GRANTED
|
||||
if (focusRequested) {
|
||||
focusRequestHandle = request
|
||||
}
|
||||
Log.i(TAG, "requestAudioFocus result=$result granted=$focusRequested")
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
val result = am.requestAudioFocus(
|
||||
audioFocusListener,
|
||||
AudioManager.STREAM_VOICE_CALL,
|
||||
AudioManager.AUDIOFOCUS_GAIN,
|
||||
)
|
||||
focusRequested = result == AudioManager.AUDIOFOCUS_REQUEST_GRANTED
|
||||
Log.i(TAG, "requestAudioFocus result=$result granted=$focusRequested")
|
||||
}
|
||||
} catch (e: SecurityException) {
|
||||
Log.e(TAG, "requestAudioFocus denied by platform: ${e.message}", e)
|
||||
focusRequested = false
|
||||
}
|
||||
}
|
||||
|
||||
private fun abandonFocus(context: Context) {
|
||||
val am = context.getSystemService(Context.AUDIO_SERVICE) as? AudioManager
|
||||
if (am == null) {
|
||||
Log.e(TAG, "AudioManager unavailable; cannot abandon audio focus")
|
||||
focusRequested = false
|
||||
focusRequestHandle = null
|
||||
return
|
||||
}
|
||||
try {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
val handle = focusRequestHandle
|
||||
focusRequestHandle = null
|
||||
if (handle != null) {
|
||||
am.abandonAudioFocusRequest(handle)
|
||||
}
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
am.abandonAudioFocus(audioFocusListener)
|
||||
}
|
||||
Log.i(TAG, "abandonAudioFocus dispatched")
|
||||
} catch (e: SecurityException) {
|
||||
Log.w(TAG, "abandonAudioFocus failed: ${e.message}", e)
|
||||
}
|
||||
focusRequested = false
|
||||
focusRequestHandle = null
|
||||
}
|
||||
}
|
||||
}
|
||||
+199
@@ -0,0 +1,199 @@
|
||||
package app.chanora.chanora_flutter
|
||||
|
||||
import android.content.Context
|
||||
import android.media.AudioDeviceCallback
|
||||
import android.media.AudioDeviceInfo
|
||||
import android.media.AudioManager
|
||||
import android.os.Build
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.util.Log
|
||||
import io.flutter.embedding.engine.FlutterEngine
|
||||
import io.flutter.plugin.common.MethodChannel
|
||||
|
||||
/**
|
||||
* Android audio lifecycle controller.
|
||||
*
|
||||
* Mirrors the iOS `AppDelegate` audio lifecycle events — route changes,
|
||||
* device connectivity changes, and app lifecycle transitions — and
|
||||
* forwards them to the Dart layer over a MethodChannel. The Dart side
|
||||
* dispatches these to the Rust bridge, the same pattern as
|
||||
* `_wireIosAudioLifecycle()` in `main.dart`.
|
||||
*
|
||||
* Trace: SDD-111 (MobileVoiceAudioBackend cross-platform)
|
||||
*
|
||||
* ## Events published
|
||||
*
|
||||
* `routeChange` — audio device connected/disconnected (payload:
|
||||
* `{"routeType": String}`). Mimics iOS
|
||||
* `handleRouteChange`.
|
||||
* `interruptionBegan` — audio interruption started (e.g. phone call).
|
||||
* `interruptionEnded` — audio interruption ended, with `shouldResume`.
|
||||
* `appDidEnterBackground` — app moved to background.
|
||||
* `appWillEnterForeground` — app returned to foreground.
|
||||
* `mediaServicesReset` — equivalent of iOS media services reset
|
||||
* (Android: audio devices changed significantly).
|
||||
*
|
||||
* ## Thread model
|
||||
*
|
||||
* All callbacks from `AudioDeviceCallback` arrive on the main thread
|
||||
* (registered with the main `Handler`). App lifecycle observation is
|
||||
* driven by the Flutter `AppLifecycleListener` on the Dart side;
|
||||
* this controller only owns the audio-device side. The Flutter side
|
||||
* is responsible for wiring lifecycle and forwarding events to Rust.
|
||||
*/
|
||||
internal class AndroidAudioLifecycleController(
|
||||
private val context: Context,
|
||||
) {
|
||||
private val audioManager: AudioManager =
|
||||
context.applicationContext.getSystemService(Context.AUDIO_SERVICE) as AudioManager
|
||||
private val mainHandler = Handler(Looper.getMainLooper())
|
||||
private var callbackRegistered = false
|
||||
|
||||
private var currentRouteType: String? = null
|
||||
|
||||
private val audioDeviceCallback = object : AudioDeviceCallback() {
|
||||
override fun onAudioDevicesAdded(addedDevices: Array<out AudioDeviceInfo>) {
|
||||
notifyRouteChange(addedDevices.firstOrNull())
|
||||
}
|
||||
|
||||
override fun onAudioDevicesRemoved(removedDevices: Array<out AudioDeviceInfo>) {
|
||||
notifyRouteChange(removedDevices.firstOrNull())
|
||||
}
|
||||
}
|
||||
|
||||
private var channel: MethodChannel? = null
|
||||
|
||||
/**
|
||||
* Attach to [flutterEngine]'s binary messenger.
|
||||
*
|
||||
* Creates a MethodChannel named `chanora/android_audio_lifecycle`
|
||||
* and starts observing audio device changes.
|
||||
*/
|
||||
fun attach(flutterEngine: FlutterEngine) {
|
||||
channel = MethodChannel(
|
||||
flutterEngine.dartExecutor.binaryMessenger,
|
||||
CHANNEL_NAME,
|
||||
)
|
||||
startObservingAudioDevices()
|
||||
Log.i(TAG, "attached to flutter engine")
|
||||
}
|
||||
|
||||
/**
|
||||
* Detach from the Flutter engine and stop observing.
|
||||
*/
|
||||
fun detach() {
|
||||
stopObservingAudioDevices()
|
||||
channel?.setMethodCallHandler(null)
|
||||
channel = null
|
||||
Log.i(TAG, "detached")
|
||||
}
|
||||
|
||||
/**
|
||||
* Call from `Activity.onResume` to re-evaluate the current route.
|
||||
*/
|
||||
fun onResume() {
|
||||
notifyRouteChange(null)
|
||||
}
|
||||
|
||||
/**
|
||||
* Call from `Activity.onDestroy` to tear down.
|
||||
*/
|
||||
fun onDestroy() {
|
||||
detach()
|
||||
}
|
||||
|
||||
private fun startObservingAudioDevices() {
|
||||
if (callbackRegistered) return
|
||||
audioManager.registerAudioDeviceCallback(audioDeviceCallback, mainHandler)
|
||||
callbackRegistered = true
|
||||
Log.d(TAG, "audio device observer started")
|
||||
}
|
||||
|
||||
private fun stopObservingAudioDevices() {
|
||||
if (!callbackRegistered) return
|
||||
audioManager.unregisterAudioDeviceCallback(audioDeviceCallback)
|
||||
callbackRegistered = false
|
||||
Log.d(TAG, "audio device observer stopped")
|
||||
}
|
||||
|
||||
private fun notifyRouteChange(
|
||||
device: AudioDeviceInfo?,
|
||||
) {
|
||||
val routeType = classifyCurrentRoute(device)
|
||||
if (routeType == currentRouteType) {
|
||||
return
|
||||
}
|
||||
currentRouteType = routeType
|
||||
Log.i(TAG, "route changed to: $routeType")
|
||||
channel?.invokeMethod(
|
||||
"handleRouteChange",
|
||||
mapOf("routeType" to routeType),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Classify the current audio output route into a stable string
|
||||
* matching the iOS route-classification schema so the Dart-side
|
||||
* parser (`_parseBridgeAudioRoute`) works identically across
|
||||
* platforms.
|
||||
*/
|
||||
private fun classifyCurrentRoute(specificDevice: AudioDeviceInfo?): String {
|
||||
// If a specific device was added/removed, prefer its type.
|
||||
if (specificDevice != null) {
|
||||
return classifyDevice(specificDevice)
|
||||
}
|
||||
|
||||
// Otherwise, classify based on the current output devices.
|
||||
val outputs = audioManager.getDevices(AudioManager.GET_DEVICES_OUTPUTS)
|
||||
if (outputs.isEmpty()) return "Unknown"
|
||||
|
||||
// Prefer wired/Bluetooth headset if connected.
|
||||
for (d in outputs) {
|
||||
val t = classifyDevice(d)
|
||||
when (t) {
|
||||
"WiredHeadset", "BluetoothHfp", "BluetoothA2dp", "UsbHeadset" -> return t
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
// Fall back to the first output device classification.
|
||||
val first = classifyDevice(outputs.first())
|
||||
return when (first) {
|
||||
"Speaker" -> "Speaker"
|
||||
"Earpiece" -> "Earpiece"
|
||||
else -> "Speaker" // Default to Speaker for unknown outputs.
|
||||
}
|
||||
}
|
||||
|
||||
private fun classifyDevice(d: AudioDeviceInfo): String = when (d.type) {
|
||||
AudioDeviceInfo.TYPE_BUILTIN_EARPIECE -> "Earpiece"
|
||||
AudioDeviceInfo.TYPE_BUILTIN_SPEAKER -> "Speaker"
|
||||
AudioDeviceInfo.TYPE_WIRED_HEADSET,
|
||||
AudioDeviceInfo.TYPE_WIRED_HEADPHONES -> "WiredHeadset"
|
||||
AudioDeviceInfo.TYPE_BLUETOOTH_SCO -> "BluetoothHfp"
|
||||
AudioDeviceInfo.TYPE_BLUETOOTH_A2DP -> "BluetoothA2dp"
|
||||
AudioDeviceInfo.TYPE_BLE_HEADSET,
|
||||
AudioDeviceInfo.TYPE_BLE_SPEAKER -> if (Build.VERSION.SDK_INT >= 31) "BluetoothHfp" else "BluetoothA2dp"
|
||||
AudioDeviceInfo.TYPE_USB_HEADSET -> "UsbHeadset"
|
||||
AudioDeviceInfo.TYPE_HDMI -> "Hdmi"
|
||||
else -> {
|
||||
if (android.os.Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU &&
|
||||
d.type == AudioDeviceInfo.TYPE_BLE_BROADCAST
|
||||
) {
|
||||
"BluetoothA2dp"
|
||||
} else {
|
||||
"Unknown"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "ChanoraAudioLifecycle"
|
||||
|
||||
/**
|
||||
* MethodChannel name for Android audio lifecycle events.
|
||||
* Mirrors `chanora/ios_audio_lifecycle` on iOS.
|
||||
*/
|
||||
const val CHANNEL_NAME: String = "chanora/android_audio_lifecycle"
|
||||
}
|
||||
}
|
||||
+241
@@ -0,0 +1,241 @@
|
||||
package app.chanora.chanora_flutter
|
||||
|
||||
import android.bluetooth.BluetoothAdapter
|
||||
import android.bluetooth.BluetoothProfile
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import android.media.AudioManager
|
||||
import android.util.Log
|
||||
|
||||
/**
|
||||
* Manages Android Bluetooth SCO (Synchronous Connection-Oriented) audio
|
||||
* for the Chanora voice session.
|
||||
*
|
||||
* Trace: SDD-110 (Android Bluetooth SCO)
|
||||
*
|
||||
* Bluetooth SCO is the low-latency, monaural audio path used by Bluetooth
|
||||
* headsets for phone calls. Without SCO, voice audio may route through
|
||||
* A2DP which is stereo, high-latency, and lacks the codec support for
|
||||
* two-way communication. On Android we must explicitly start/stop SCO
|
||||
* when a Bluetooth headset is present; the platform does not auto-manage
|
||||
* this for VoIP apps.
|
||||
*
|
||||
* ## Lifecycle
|
||||
*
|
||||
* 1. [start] — called by the Rust audio engine (via JNI) after the Oboe
|
||||
* voice streams are opened. Calls `AudioManager.startBluetoothSco()`
|
||||
* if a Bluetooth SCO-capable device is connected and registers a
|
||||
* `BroadcastReceiver` for `ACTION_SCO_AUDIO_STATE_UPDATED`.
|
||||
* 2. SCO state changes are forwarded to the Rust engine via
|
||||
* `publishScoStateChange(int)`, a JNI function declared in
|
||||
* `crates/chanora_audio/src/android_voice_unit.rs`.
|
||||
* 3. [stop] — called by the Rust engine on voice stop. Calls
|
||||
* `AudioManager.stopBluetoothSco()` and unregisters the receiver.
|
||||
*
|
||||
* ## Thread model
|
||||
*
|
||||
* `start` / `stop` are called from a tokio worker thread (via JNI).
|
||||
* `AudioManager.startBluetoothSco` is asynchronous — the platform
|
||||
* responds with `ACTION_SCO_AUDIO_STATE_UPDATED` which arrives on the
|
||||
* main thread via the `BroadcastReceiver`.
|
||||
*/
|
||||
internal class AndroidBluetoothScoController {
|
||||
|
||||
companion object {
|
||||
private const val TAG = "ChanoraBluetoothSco"
|
||||
|
||||
/**
|
||||
* JNI entry point implemented in
|
||||
* `crates/chanora_audio/src/android_voice_unit.rs`.
|
||||
*
|
||||
* Kotlin calls this from the SCO state `BroadcastReceiver` to
|
||||
* forward the state integer to the Rust engine's BackendEvent
|
||||
* channel.
|
||||
*/
|
||||
@JvmStatic
|
||||
external fun publishScoStateChange(state: Int)
|
||||
|
||||
/**
|
||||
* Start Bluetooth SCO management.
|
||||
*
|
||||
* Called from Rust via JNI after voice unit start.
|
||||
* Idempotent: repeated calls against an already-started instance
|
||||
* are silently ignored.
|
||||
*/
|
||||
@JvmStatic
|
||||
fun start(context: Context) {
|
||||
val appContext = context.applicationContext
|
||||
if (scoStarted) {
|
||||
Log.d(TAG, "start() called but SCO already active; no-op")
|
||||
return
|
||||
}
|
||||
scoStarted = true
|
||||
registerScoReceiver(appContext)
|
||||
tryStartSco(appContext)
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop Bluetooth SCO management.
|
||||
*
|
||||
* Called from Rust via JNI on voice stop.
|
||||
* Idempotent: safe to call when SCO is not active.
|
||||
*/
|
||||
@JvmStatic
|
||||
fun stop(context: Context) {
|
||||
val appContext = context.applicationContext
|
||||
scoStarted = false
|
||||
unregisterScoReceiver(appContext)
|
||||
tryStopSco(appContext)
|
||||
}
|
||||
|
||||
private var scoStarted: Boolean = false
|
||||
private var receiverRegistered: Boolean = false
|
||||
|
||||
private val scoReceiver = object : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context?, intent: Intent?) {
|
||||
if (intent?.action != AudioManager.ACTION_SCO_AUDIO_STATE_UPDATED) return
|
||||
val state = intent.getIntExtra(
|
||||
AudioManager.EXTRA_SCO_AUDIO_STATE,
|
||||
AudioManager.SCO_AUDIO_STATE_ERROR,
|
||||
)
|
||||
val prevState = intent.getIntExtra(
|
||||
AudioManager.EXTRA_SCO_AUDIO_PREVIOUS_STATE,
|
||||
-1,
|
||||
)
|
||||
Log.i(
|
||||
TAG,
|
||||
"SCO state: ${scoStateName(state)} (prev: ${scoStateName(prevState)})",
|
||||
)
|
||||
try {
|
||||
publishScoStateChange(state)
|
||||
} catch (t: Throwable) {
|
||||
Log.w(TAG, "publishScoStateChange JNI failed: ${t.message}", t)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun registerScoReceiver(context: Context) {
|
||||
if (receiverRegistered) return
|
||||
try {
|
||||
val filter = IntentFilter(AudioManager.ACTION_SCO_AUDIO_STATE_UPDATED)
|
||||
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.TIRAMISU) {
|
||||
context.registerReceiver(scoReceiver, filter, Context.RECEIVER_NOT_EXPORTED)
|
||||
} else {
|
||||
@Suppress("UnspecifiedRegisterReceiverFlag")
|
||||
context.registerReceiver(scoReceiver, filter)
|
||||
}
|
||||
receiverRegistered = true
|
||||
Log.d(TAG, "SCO receiver registered")
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Failed to register SCO receiver: ${e.message}", e)
|
||||
}
|
||||
}
|
||||
|
||||
private fun unregisterScoReceiver(context: Context) {
|
||||
if (!receiverRegistered) return
|
||||
try {
|
||||
context.unregisterReceiver(scoReceiver)
|
||||
receiverRegistered = false
|
||||
Log.d(TAG, "SCO receiver unregistered")
|
||||
} catch (e: IllegalArgumentException) {
|
||||
// Already unregistered — ignore silently.
|
||||
receiverRegistered = false
|
||||
}
|
||||
}
|
||||
|
||||
private val bluetoothAdapter: BluetoothAdapter?
|
||||
get() = try {
|
||||
BluetoothAdapter.getDefaultAdapter()
|
||||
} catch (e: SecurityException) {
|
||||
Log.w(TAG, "BluetoothAdapter unavailable: ${e.message}")
|
||||
null
|
||||
}
|
||||
|
||||
private fun isBluetoothScoOn(am: AudioManager): Boolean = try {
|
||||
am.isBluetoothScoOn
|
||||
} catch (e: SecurityException) {
|
||||
Log.w(TAG, "isBluetoothScoOn failed: ${e.message}")
|
||||
false
|
||||
}
|
||||
|
||||
private fun isBluetoothScoAvailableOffCall(am: AudioManager): Boolean = try {
|
||||
am.isBluetoothScoAvailableOffCall
|
||||
} catch (e: SecurityException) {
|
||||
Log.w(TAG, "isBluetoothScoAvailableOffCall failed: ${e.message}")
|
||||
false
|
||||
}
|
||||
|
||||
private fun tryStartSco(context: Context) {
|
||||
val am = context.getSystemService(Context.AUDIO_SERVICE) as? AudioManager
|
||||
if (am == null) {
|
||||
Log.e(TAG, "AudioManager unavailable; cannot start SCO")
|
||||
return
|
||||
}
|
||||
|
||||
if (isBluetoothScoOn(am)) {
|
||||
Log.i(TAG, "SCO already on; no-op")
|
||||
return
|
||||
}
|
||||
|
||||
val adapter = bluetoothAdapter
|
||||
if (adapter == null || !adapter.isEnabled) {
|
||||
Log.i(TAG, "Bluetooth not enabled; skipping SCO start")
|
||||
return
|
||||
}
|
||||
|
||||
val scoAvailableOffCall = isBluetoothScoAvailableOffCall(am)
|
||||
if (!scoAvailableOffCall) {
|
||||
Log.i(TAG, "Bluetooth SCO not available off-call; skipping SCO start")
|
||||
return
|
||||
}
|
||||
|
||||
val hasScoHeadset = try {
|
||||
val state = adapter.getProfileConnectionState(BluetoothProfile.HEADSET)
|
||||
state == BluetoothProfile.STATE_CONNECTED
|
||||
} catch (e: SecurityException) {
|
||||
Log.w(TAG, "getProfileConnectionState(HEADSET) failed: ${e.message}")
|
||||
false
|
||||
}
|
||||
|
||||
if (!hasScoHeadset) {
|
||||
Log.i(TAG, "No SCO-capable Bluetooth headset connected; skipping SCO start")
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
am.startBluetoothSco()
|
||||
Log.i(TAG, "startBluetoothSco() dispatched")
|
||||
} catch (e: SecurityException) {
|
||||
Log.e(TAG, "startBluetoothSco denied: ${e.message}", e)
|
||||
}
|
||||
}
|
||||
|
||||
private fun tryStopSco(context: Context) {
|
||||
val am = context.getSystemService(Context.AUDIO_SERVICE) as? AudioManager
|
||||
if (am == null) {
|
||||
Log.e(TAG, "AudioManager unavailable; cannot stop SCO")
|
||||
return
|
||||
}
|
||||
if (!isBluetoothScoOn(am)) {
|
||||
Log.d(TAG, "SCO not on; no-op")
|
||||
return
|
||||
}
|
||||
try {
|
||||
am.stopBluetoothSco()
|
||||
Log.i(TAG, "stopBluetoothSco() dispatched")
|
||||
} catch (e: SecurityException) {
|
||||
Log.w(TAG, "stopBluetoothSco denied: ${e.message}", e)
|
||||
}
|
||||
}
|
||||
|
||||
private fun scoStateName(state: Int): String = when (state) {
|
||||
AudioManager.SCO_AUDIO_STATE_DISCONNECTED -> "DISCONNECTED"
|
||||
AudioManager.SCO_AUDIO_STATE_CONNECTED -> "CONNECTED"
|
||||
AudioManager.SCO_AUDIO_STATE_CONNECTING -> "CONNECTING"
|
||||
AudioManager.SCO_AUDIO_STATE_ERROR -> "ERROR"
|
||||
else -> "UNKNOWN($state)"
|
||||
}
|
||||
}
|
||||
}
|
||||
+16
@@ -76,6 +76,11 @@ class MainActivity : FlutterActivity() {
|
||||
private var audioOutputEvents: EventChannel? = null
|
||||
private var audioOutputController: AndroidAudioOutputController? = null
|
||||
|
||||
// SDD-111: Android audio lifecycle controller (route changes, device
|
||||
// add/remove, app lifecycle). Mirrors the iOS
|
||||
// `chanora/ios_audio_lifecycle` channel pattern.
|
||||
private var audioLifecycleController: AndroidAudioLifecycleController? = null
|
||||
|
||||
// SDD-028 (M-3 strict-review fix): the back-intent bridge is owned
|
||||
// by this Activity instance, not a process-wide `object`. Constructed
|
||||
// in configureFlutterEngine and cleared in onDestroy after detach().
|
||||
@@ -202,6 +207,12 @@ class MainActivity : FlutterActivity() {
|
||||
)
|
||||
this.audioOutputEvents = audioOutputEvents
|
||||
audioOutputEvents.setStreamHandler(audioOutputController)
|
||||
|
||||
// SDD-111: attach the Android audio lifecycle controller
|
||||
// (route changes, device add/remove, interruptions).
|
||||
val lifecycleController = AndroidAudioLifecycleController(applicationContext)
|
||||
lifecycleController.attach(flutterEngine)
|
||||
audioLifecycleController = lifecycleController
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
@@ -217,6 +228,8 @@ class MainActivity : FlutterActivity() {
|
||||
// SDD-106: state already emitted by AndroidPermissionRequester via stateChangeListener; do NOT double-emit (M-4 fix)
|
||||
requester.onResume(this) { _ -> }
|
||||
}
|
||||
// SDD-111: re-evaluate the current audio route on resume.
|
||||
audioLifecycleController?.onResume()
|
||||
}
|
||||
|
||||
override fun onRequestPermissionsResult(
|
||||
@@ -252,6 +265,9 @@ class MainActivity : FlutterActivity() {
|
||||
audioOutputChannel?.setMethodCallHandler(null)
|
||||
audioOutputEvents?.setStreamHandler(null)
|
||||
audioOutputController?.detach()
|
||||
// SDD-111: detach the audio lifecycle controller.
|
||||
audioLifecycleController?.onDestroy()
|
||||
audioLifecycleController = null
|
||||
permissionRequester = null
|
||||
permissionsChannel = null
|
||||
audioOutputChannel = null
|
||||
|
||||
+9
@@ -65,6 +65,15 @@ internal object MethodChannels {
|
||||
*/
|
||||
const val METHOD_POP_TO_SYSTEM: String = "popToSystem"
|
||||
|
||||
/**
|
||||
* Channel for Android audio lifecycle events (route changes,
|
||||
* interruptions, app lifecycle). Mirrors the iOS
|
||||
* `chanora/ios_audio_lifecycle` channel.
|
||||
*
|
||||
* Trace: SDD-111 (cross-platform mobile voice backend)
|
||||
*/
|
||||
const val ANDROID_AUDIO_LIFECYCLE: String = "chanora/android_audio_lifecycle"
|
||||
|
||||
const val AUDIO_OUTPUT: String = "app.audio_output"
|
||||
const val AUDIO_OUTPUT_EVENTS: String = "app.audio_output/events"
|
||||
const val METHOD_GET_OUTPUT_DEVICES: String = "getOutputDevices"
|
||||
|
||||
@@ -18,6 +18,7 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:package_info_plus/package_info_plus.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:flutter_foreground_task/flutter_foreground_task.dart';
|
||||
|
||||
import 'l10n/generated/app_localizations.dart';
|
||||
import 'services/android_permissions_service.dart';
|
||||
@@ -30,6 +31,8 @@ import 'widgets/voice_platform.dart';
|
||||
import 'widgets/voice_bar.dart';
|
||||
import 'widgets/voice_compact.dart';
|
||||
import 'widgets/voice_settings.dart';
|
||||
import 'widgets/bbcode_text.dart';
|
||||
import 'services/link_trust_service.dart';
|
||||
|
||||
bool get _isMacOS => !kIsWeb && Platform.isMacOS;
|
||||
|
||||
@@ -62,11 +65,12 @@ Future<void> _configureBundledVadModels() async {
|
||||
assetPath: _sileroVadAsset,
|
||||
fileName: 'silero_vad.onnx',
|
||||
);
|
||||
await _copyBundledAssetToDocuments(
|
||||
final ten = await _copyBundledAssetToDocuments(
|
||||
assetPath: _tenVadAsset,
|
||||
fileName: 'ten_vad.onnx',
|
||||
);
|
||||
await rust.setVadModelPath(path: silero.path);
|
||||
await rust.setTenVadModelPath(path: ten.path);
|
||||
}
|
||||
|
||||
/// Top padding for macOS to clear traffic-light buttons.
|
||||
@@ -136,7 +140,7 @@ String? _pttDisplayLabelForKey(LogicalKeyboardKey k) {
|
||||
/// pubspec.yaml advances (e.g. rc.8 -> rc.9 -> 1.0.0). The
|
||||
/// build-counter suffix changes automatically on every pubspec
|
||||
/// `+<n>` bump because Flutter writes it into Info.plist.
|
||||
const String _kSemverBaseline = 'v1.0.0-rc.8';
|
||||
const String _kSemverBaseline = 'v0.1.0';
|
||||
String _kAppVersion = _kSemverBaseline;
|
||||
|
||||
Future<void> main() async {
|
||||
@@ -146,6 +150,8 @@ Future<void> main() async {
|
||||
unawaited(_wireStorage());
|
||||
unawaited(_wireConnectivity());
|
||||
_wireIosAudioLifecycle();
|
||||
_wireAndroidAudioLifecycle();
|
||||
await _configureBundledVadModels();
|
||||
runApp(const ChanoraApp());
|
||||
}
|
||||
|
||||
@@ -214,6 +220,37 @@ void _wireIosAudioLifecycle() {
|
||||
});
|
||||
}
|
||||
|
||||
/// Wire the Android audio lifecycle MethodChannel.
|
||||
///
|
||||
/// Kotlin side (`AndroidAudioLifecycleController`) posts route-change
|
||||
/// events through `FlutterMethodChannel` named
|
||||
/// `"chanora/android_audio_lifecycle"`. This handler dispatches them to
|
||||
/// the FRB bridge functions on the Rust side, mirroring the iOS pattern.
|
||||
void _wireAndroidAudioLifecycle() {
|
||||
if (!Platform.isAndroid) return;
|
||||
const channel = MethodChannel('chanora/android_audio_lifecycle');
|
||||
channel.setMethodCallHandler((call) async {
|
||||
try {
|
||||
switch (call.method) {
|
||||
case 'handleRouteChange':
|
||||
final args = call.arguments;
|
||||
final routeStr = args is Map
|
||||
? (args['routeType'] as String? ?? 'Unknown')
|
||||
: (args as String? ?? 'Unknown');
|
||||
final route = _parseBridgeAudioRoute(routeStr);
|
||||
rust.handleRouteChange(route: route);
|
||||
break;
|
||||
default:
|
||||
// Unknown method — ignore gracefully rather than crashing.
|
||||
break;
|
||||
}
|
||||
} catch (_) {
|
||||
// Errors from the Rust side are already logged there;
|
||||
// don't propagate exceptions to the Android framework.
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Populate `_kAppVersion` by suffixing the platform-canonical
|
||||
/// build number to `_kSemverBaseline`. Format:
|
||||
/// `v1.0.0-rc.8+<build>` (e.g. `v1.0.0-rc.8+64`). The build
|
||||
@@ -728,6 +765,15 @@ class _BetaHomeState extends State<_BetaHome> {
|
||||
_phase = _Phase.connected;
|
||||
_applySnapshot(snap);
|
||||
});
|
||||
try {
|
||||
await FlutterForegroundTask.startService(
|
||||
notificationTitle: 'Chanora',
|
||||
notificationText: 'Connected to ${snap.serverName}',
|
||||
notificationButtons: const [
|
||||
NotificationButton(id: 'disconnect', text: 'Disconnect'),
|
||||
],
|
||||
);
|
||||
} catch (_) {}
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
final errorStr = e.toString();
|
||||
@@ -1186,6 +1232,9 @@ class _BetaHomeState extends State<_BetaHome> {
|
||||
try {
|
||||
await rust.disconnect();
|
||||
} catch (_) {}
|
||||
try {
|
||||
await FlutterForegroundTask.stopService();
|
||||
} catch (_) {}
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_phase = _Phase.idle;
|
||||
@@ -1506,6 +1555,7 @@ class _BetaHomeState extends State<_BetaHome> {
|
||||
icon: Icon(_hardMute ? Icons.mic_off : Icons.mic),
|
||||
isSelected: _hardMute,
|
||||
selectedIcon: const Icon(Icons.mic_off),
|
||||
color: _hardMute ? theme.colorScheme.error : null,
|
||||
onPressed: _onToggleHardMute,
|
||||
),
|
||||
IconButton(
|
||||
@@ -1513,6 +1563,7 @@ class _BetaHomeState extends State<_BetaHome> {
|
||||
icon: Icon(_outputMuted ? Icons.headset_off : Icons.headset),
|
||||
isSelected: _outputMuted,
|
||||
selectedIcon: const Icon(Icons.headset_off),
|
||||
color: _outputMuted ? theme.colorScheme.error : null,
|
||||
onPressed: _toggleOutputMute,
|
||||
),
|
||||
],
|
||||
@@ -1532,6 +1583,10 @@ class _BetaHomeState extends State<_BetaHome> {
|
||||
|
||||
const headerTitle = SizedBox.shrink();
|
||||
|
||||
final appBarTitle = _phase == _Phase.connected
|
||||
? Text(_snapshot?.serverName ?? l10n.appTitle, style: theme.textTheme.titleMedium)
|
||||
: headerTitle;
|
||||
|
||||
final bodyContent = LayoutBuilder(
|
||||
builder: (ctx, bodyConstraints) {
|
||||
const wideBreakpoint = 600.0;
|
||||
@@ -1554,7 +1609,8 @@ class _BetaHomeState extends State<_BetaHome> {
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
if (!isWideSnapshot) ...[banner, const SizedBox(height: 12)],
|
||||
Text(statusText(), style: theme.textTheme.titleMedium),
|
||||
if (_phase != _Phase.connected)
|
||||
Text(statusText(), style: theme.textTheme.titleMedium),
|
||||
if (_lostReason != null || _reconnectAttempt != null) ...[
|
||||
const SizedBox(height: 8),
|
||||
Container(
|
||||
@@ -1707,6 +1763,8 @@ class _BetaHomeState extends State<_BetaHome> {
|
||||
pttBoundKeyLabel: _pttBoundKeyLabel,
|
||||
audioStats: _audioStats,
|
||||
isTouchOnly: isTouchOnlyPttHost,
|
||||
inputMuted: _hardMute,
|
||||
outputMuted: _outputMuted,
|
||||
onTap: () => _onOpenVoiceDetailsSheet(),
|
||||
),
|
||||
if (_inChannel &&
|
||||
@@ -1756,7 +1814,7 @@ class _BetaHomeState extends State<_BetaHome> {
|
||||
}
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: headerTitle, actions: headerActions),
|
||||
appBar: AppBar(title: appBarTitle, actions: headerActions),
|
||||
body: SafeArea(
|
||||
top: false,
|
||||
child: Padding(padding: const EdgeInsets.all(16), child: bodyContent),
|
||||
@@ -2168,6 +2226,75 @@ class _BookmarkList extends StatelessWidget {
|
||||
///
|
||||
/// Driven by the `BridgeEvent::PttCapability` stream published by
|
||||
/// the `PttController` (SDD-088). The `_BetaHomeState` listener
|
||||
class _WelcomeMessageTile extends StatefulWidget {
|
||||
const _WelcomeMessageTile({required this.welcomeMessage});
|
||||
|
||||
final String welcomeMessage;
|
||||
|
||||
@override
|
||||
State<_WelcomeMessageTile> createState() => _WelcomeMessageTileState();
|
||||
}
|
||||
|
||||
class _WelcomeMessageTileState extends State<_WelcomeMessageTile> {
|
||||
bool _expanded = true;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
InkWell(
|
||||
onTap: () {
|
||||
HapticFeedback.selectionClick();
|
||||
setState(() => _expanded = !_expanded);
|
||||
},
|
||||
borderRadius: const BorderRadius.vertical(top: Radius.circular(6)),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
_expanded ? Icons.expand_less : Icons.expand_more,
|
||||
size: 18,
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
'Server welcome message',
|
||||
style: theme.textTheme.labelMedium?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
AnimatedCrossFade(
|
||||
firstChild: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(8, 0, 8, 8),
|
||||
child: BbCodeText(
|
||||
widget.welcomeMessage,
|
||||
linkTrust: LinkTrustService.instance,
|
||||
),
|
||||
),
|
||||
secondChild: const SizedBox(width: double.infinity),
|
||||
crossFadeState: _expanded
|
||||
? CrossFadeState.showFirst
|
||||
: CrossFadeState.showSecond,
|
||||
duration: const Duration(milliseconds: 200),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// updates the props on each transition.
|
||||
class _SnapshotView extends StatelessWidget {
|
||||
const _SnapshotView({
|
||||
@@ -2242,17 +2369,7 @@ class _SnapshotView extends StatelessWidget {
|
||||
),
|
||||
if (snapshot.welcomeMessage.isNotEmpty) ...[
|
||||
const SizedBox(height: 8),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(8),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
child: Text(
|
||||
snapshot.welcomeMessage,
|
||||
style: theme.textTheme.bodySmall,
|
||||
),
|
||||
),
|
||||
_WelcomeMessageTile(welcomeMessage: snapshot.welcomeMessage),
|
||||
],
|
||||
const Divider(height: 24),
|
||||
Text(l10n.channelsHeading, style: theme.textTheme.titleMedium),
|
||||
@@ -2271,7 +2388,6 @@ class _SnapshotView extends StatelessWidget {
|
||||
: null,
|
||||
),
|
||||
title: Text(ch.name),
|
||||
subtitle: Text('id=${ch.id} parent=${ch.parent}'),
|
||||
selected: ch.id == currentVoiceChannelId,
|
||||
onTap:
|
||||
hasJoinPending ||
|
||||
@@ -2284,7 +2400,8 @@ class _SnapshotView extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
for (final cl in byChannel[ch.id] ?? const <rust.BridgeClient>[])
|
||||
_clientTile(theme, cl, (depthById[ch.id] ?? 0) * indentPerLevel),
|
||||
if (!cl.isServerQuery)
|
||||
_clientTile(theme, cl, (depthById[ch.id] ?? 0) * indentPerLevel),
|
||||
],
|
||||
],
|
||||
);
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
|
||||
class LinkTrustService extends ChangeNotifier {
|
||||
static LinkTrustService? _instance;
|
||||
final Set<String> _trusted = {};
|
||||
bool _loaded = false;
|
||||
|
||||
static LinkTrustService get instance {
|
||||
_instance ??= LinkTrustService._();
|
||||
return _instance!;
|
||||
}
|
||||
|
||||
LinkTrustService._() {
|
||||
_load();
|
||||
}
|
||||
|
||||
Future<void> _load() async {
|
||||
if (_loaded) return;
|
||||
_loaded = true;
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final domains = prefs.getStringList('trusted_domains') ?? [];
|
||||
_trusted.addAll(domains);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
bool isTrusted(String host) {
|
||||
host = host.toLowerCase();
|
||||
for (final pattern in _trusted) {
|
||||
if (_matches(host, pattern)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
Future<void> addTrustedDomain(String host) async {
|
||||
host = host.toLowerCase();
|
||||
_trusted.add(host);
|
||||
notifyListeners();
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setStringList('trusted_domains', _trusted.toList());
|
||||
}
|
||||
|
||||
bool _matches(String host, String pattern) {
|
||||
if (pattern.startsWith('*.')) {
|
||||
final suffix = pattern.substring(2);
|
||||
return host == suffix || host.endsWith('.$suffix');
|
||||
}
|
||||
return host == pattern;
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool?> showLinkTrustDialog(BuildContext context, String domain) async {
|
||||
bool remember = false;
|
||||
return showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => StatefulBuilder(
|
||||
builder: (ctx, setDialogState) => AlertDialog(
|
||||
title: const Text('Open external link?'),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('You are about to open a link to:\n\n$domain'),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 24,
|
||||
height: 24,
|
||||
child: Checkbox(
|
||||
value: remember,
|
||||
onChanged: (v) => setDialogState(() => remember = v ?? false),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
const Flexible(
|
||||
child: Text('Trust all links from this domain'),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(ctx).pop(null),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(ctx).pop(remember),
|
||||
child: const Text('Open'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -10,7 +10,7 @@ import 'package:freezed_annotation/freezed_annotation.dart' hide protected;
|
||||
part 'api.freezed.dart';
|
||||
|
||||
// These functions are ignored because they are not marked as `pub`: `install_panic_diagnostic_hook`, `log_file_path`, `log_sink`, `map_join_error_code`, `map_join_sync_state`, `open_log_file`, `permission_events`, `publish_permission_state`, `runtime`, `session`, `task_join_error`, `transmit_mode_from_u8`
|
||||
// These function are ignored because they are on traits that is not defined in current crate (put an empty `#[frb]` on it to unignore): `assert_fields_are_eq`, `assert_fields_are_eq`, `assert_fields_are_eq`, `assert_fields_are_eq`, `assert_fields_are_eq`, `assert_fields_are_eq`, `assert_fields_are_eq`, `assert_fields_are_eq`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `eq`, `eq`, `eq`, `eq`, `eq`, `eq`, `eq`, `eq`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`
|
||||
// These function are ignored because they are on traits that is not defined in current crate (put an empty `#[frb]` on it to unignore): `assert_fields_are_eq`, `assert_fields_are_eq`, `assert_fields_are_eq`, `assert_fields_are_eq`, `assert_fields_are_eq`, `assert_fields_are_eq`, `assert_fields_are_eq`, `assert_fields_are_eq`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `eq`, `eq`, `eq`, `eq`, `eq`, `eq`, `eq`, `eq`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from`
|
||||
// These functions are ignored (category: IgnoreBecauseExplicitAttribute): `from_kotlin_str`, `to_permission_gate`
|
||||
|
||||
/// Return the platform-conventional log-file path as a string, or
|
||||
@@ -46,10 +46,24 @@ Future<bool> isConnected() => RustLib.instance.api.crateApiIsConnected();
|
||||
void handleRouteChange({required BridgeAudioRoute route}) =>
|
||||
RustLib.instance.api.crateApiHandleRouteChange(route: route);
|
||||
|
||||
/// Handle iOS AVAudioSession media-services reset.
|
||||
/// Handle iOS AVAudioSession media-services reset (legacy, no route arg).
|
||||
///
|
||||
/// Called by the existing FRB-generated Dart binding. Uses
|
||||
/// `AudioRoute::Unknown` which triggers a route-change recompute.
|
||||
/// The AppDelegate now also calls `handle_media_services_reset_with_route`
|
||||
/// directly after rebuilding the session.
|
||||
void handleMediaServicesReset() =>
|
||||
RustLib.instance.api.crateApiHandleMediaServicesReset();
|
||||
|
||||
/// Handle iOS AVAudioSession media-services reset with the current
|
||||
/// route class. Called by AppDelegate after rebuilding the session.
|
||||
///
|
||||
/// `route_class` is the Swift-side route class string (e.g. "Speaker").
|
||||
void handleMediaServicesResetWithRoute({required String routeClass}) => RustLib
|
||||
.instance
|
||||
.api
|
||||
.crateApiHandleMediaServicesResetWithRoute(routeClass: routeClass);
|
||||
|
||||
/// Handle iOS AVAudioSession interruption begin (SDD-101).
|
||||
void handleInterruptionBegan() =>
|
||||
RustLib.instance.api.crateApiHandleInterruptionBegan();
|
||||
@@ -229,59 +243,27 @@ Future<BridgeAudioStats> audioStats() =>
|
||||
/// Apply the P1 audio-processing config.
|
||||
Future<void> setAudioProcessingConfig({
|
||||
required BridgeAudioProcessingConfig config,
|
||||
}) async {
|
||||
_lastAppliedAudioConfig = config;
|
||||
return RustLib.instance.api.crateApiSetAudioProcessingConfig(config: config);
|
||||
}
|
||||
}) => RustLib.instance.api.crateApiSetAudioProcessingConfig(config: config);
|
||||
|
||||
/// Read the current audio-processing config.
|
||||
///
|
||||
/// Returns the live config as last applied to the audio engine.
|
||||
/// Returns a default config when no session is active.
|
||||
Future<BridgeAudioProcessingConfig> getAudioProcessingConfig() =>
|
||||
RustLib.instance.api.crateApiGetAudioProcessingConfig();
|
||||
|
||||
/// Read P1 audio-processing diagnostics.
|
||||
Future<BridgeAudioProcessingStats> audioProcessingStats() =>
|
||||
RustLib.instance.api.crateApiAudioProcessingStats();
|
||||
|
||||
/// Read the current audio-processing config.
|
||||
///
|
||||
/// Derives the config from [audioProcessingStats] for the route/backend
|
||||
/// fields, and returns the last value applied via [setAudioProcessingConfig]
|
||||
/// for timing/debug fields. Falls back to P1 spec defaults on first call.
|
||||
Future<BridgeAudioProcessingConfig> getAudioProcessingConfig() async {
|
||||
BridgeAudioProcessingStats? stats;
|
||||
try {
|
||||
stats = await audioProcessingStats();
|
||||
} catch (_) {}
|
||||
|
||||
final last = _lastAppliedAudioConfig;
|
||||
return BridgeAudioProcessingConfig(
|
||||
route: stats?.audioRoute ?? last?.route ?? BridgeAudioRoute.unknown,
|
||||
iosMode:
|
||||
stats?.iosVoiceProcessingMode ??
|
||||
last?.iosMode ??
|
||||
BridgeIosVoiceProcessingMode.platformVoiceProcessing,
|
||||
processingBackend:
|
||||
stats?.processingBackend ??
|
||||
last?.processingBackend ??
|
||||
BridgeAudioBackend.platformVoiceProcessing,
|
||||
vadBackend:
|
||||
stats?.vadBackend ?? last?.vadBackend ?? BridgeVadBackend.sileroOnnx,
|
||||
aec: last?.aec ?? BridgeEffectOwner.platform,
|
||||
ns: last?.ns ?? BridgeEffectOwner.platform,
|
||||
agc: last?.agc ?? BridgeEffectOwner.platform,
|
||||
hpfEnabled: last?.hpfEnabled ?? true,
|
||||
limiterEnabled: last?.limiterEnabled ?? true,
|
||||
vadHangoverMs: last?.vadHangoverMs ?? 500,
|
||||
vadPreRollMs: last?.vadPreRollMs ?? 160,
|
||||
vadMinTxMs: last?.vadMinTxMs ?? 200,
|
||||
debugWavDumpEnabled: last?.debugWavDumpEnabled ?? false,
|
||||
);
|
||||
}
|
||||
|
||||
/// Last config applied via [setAudioProcessingConfig]. Used by
|
||||
/// [getAudioProcessingConfig] to preserve timing/debug values across calls.
|
||||
BridgeAudioProcessingConfig? _lastAppliedAudioConfig;
|
||||
|
||||
/// Configure the VAD model path.
|
||||
Future<void> setVadModelPath({required String path}) =>
|
||||
RustLib.instance.api.crateApiSetVadModelPath(path: path);
|
||||
|
||||
/// Configure the TEN VAD ONNX model path.
|
||||
Future<void> setTenVadModelPath({required String path}) =>
|
||||
RustLib.instance.api.crateApiSetTenVadModelPath(path: path);
|
||||
|
||||
/// Enable or disable audio debug WAV dumping.
|
||||
Future<void> enableAudioDebugWavDump({required bool enabled}) =>
|
||||
RustLib.instance.api.crateApiEnableAudioDebugWavDump(enabled: enabled);
|
||||
|
||||
@@ -67,7 +67,7 @@ class RustLib extends BaseEntrypoint<RustLibApi, RustLibApiImpl, RustLibWire> {
|
||||
String get codegenVersion => '2.12.0';
|
||||
|
||||
@override
|
||||
int get rustContentHash => -1835973251;
|
||||
int get rustContentHash => -436507436;
|
||||
|
||||
static const kDefaultExternalLibraryLoaderConfig =
|
||||
ExternalLibraryLoaderConfig(
|
||||
@@ -103,6 +103,8 @@ abstract class RustLibApi extends BaseApi {
|
||||
|
||||
String crateApiExportDiagnostics();
|
||||
|
||||
Future<BridgeAudioProcessingConfig> crateApiGetAudioProcessingConfig();
|
||||
|
||||
Future<(String, String)> crateApiGetPttBinding();
|
||||
|
||||
Future<int> crateApiGetReleaseTailMs();
|
||||
@@ -115,6 +117,8 @@ abstract class RustLibApi extends BaseApi {
|
||||
|
||||
void crateApiHandleMediaServicesReset();
|
||||
|
||||
void crateApiHandleMediaServicesResetWithRoute({required String routeClass});
|
||||
|
||||
void crateApiHandleRouteChange({required BridgeAudioRoute route});
|
||||
|
||||
Future<void> crateApiInitStorage({required String dir});
|
||||
@@ -159,6 +163,8 @@ abstract class RustLibApi extends BaseApi {
|
||||
|
||||
Future<void> crateApiSetReleaseTailMs({required int ms});
|
||||
|
||||
Future<void> crateApiSetTenVadModelPath({required String path});
|
||||
|
||||
Future<void> crateApiSetTransmitMode({required BridgeTransmitMode mode});
|
||||
|
||||
Future<void> crateApiSetVadModelPath({required String path});
|
||||
@@ -469,7 +475,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
const TaskConstMeta(debugName: "export_diagnostics", argNames: []);
|
||||
|
||||
@override
|
||||
Future<(String, String)> crateApiGetPttBinding() {
|
||||
Future<BridgeAudioProcessingConfig> crateApiGetAudioProcessingConfig() {
|
||||
return handler.executeNormal(
|
||||
NormalTask(
|
||||
callFfi: (port_) {
|
||||
@@ -481,6 +487,36 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
port: port_,
|
||||
);
|
||||
},
|
||||
codec: SseCodec(
|
||||
decodeSuccessData: sse_decode_bridge_audio_processing_config,
|
||||
decodeErrorData: sse_decode_bridge_error,
|
||||
),
|
||||
constMeta: kCrateApiGetAudioProcessingConfigConstMeta,
|
||||
argValues: [],
|
||||
apiImpl: this,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
TaskConstMeta get kCrateApiGetAudioProcessingConfigConstMeta =>
|
||||
const TaskConstMeta(
|
||||
debugName: "get_audio_processing_config",
|
||||
argNames: [],
|
||||
);
|
||||
|
||||
@override
|
||||
Future<(String, String)> crateApiGetPttBinding() {
|
||||
return handler.executeNormal(
|
||||
NormalTask(
|
||||
callFfi: (port_) {
|
||||
final serializer = SseSerializer(generalizedFrbRustBinding);
|
||||
pdeCallFfi(
|
||||
generalizedFrbRustBinding,
|
||||
serializer,
|
||||
funcId: 12,
|
||||
port: port_,
|
||||
);
|
||||
},
|
||||
codec: SseCodec(
|
||||
decodeSuccessData: sse_decode_record_string_string,
|
||||
decodeErrorData: null,
|
||||
@@ -504,7 +540,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
pdeCallFfi(
|
||||
generalizedFrbRustBinding,
|
||||
serializer,
|
||||
funcId: 12,
|
||||
funcId: 13,
|
||||
port: port_,
|
||||
);
|
||||
},
|
||||
@@ -531,7 +567,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
pdeCallFfi(
|
||||
generalizedFrbRustBinding,
|
||||
serializer,
|
||||
funcId: 13,
|
||||
funcId: 14,
|
||||
port: port_,
|
||||
);
|
||||
},
|
||||
@@ -555,7 +591,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
SyncTask(
|
||||
callFfi: () {
|
||||
final serializer = SseSerializer(generalizedFrbRustBinding);
|
||||
return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 14)!;
|
||||
return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 15)!;
|
||||
},
|
||||
codec: SseCodec(
|
||||
decodeSuccessData: sse_decode_unit,
|
||||
@@ -578,7 +614,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
callFfi: () {
|
||||
final serializer = SseSerializer(generalizedFrbRustBinding);
|
||||
sse_encode_bool(shouldResume, serializer);
|
||||
return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 15)!;
|
||||
return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 16)!;
|
||||
},
|
||||
codec: SseCodec(
|
||||
decodeSuccessData: sse_decode_unit,
|
||||
@@ -603,7 +639,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
SyncTask(
|
||||
callFfi: () {
|
||||
final serializer = SseSerializer(generalizedFrbRustBinding);
|
||||
return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 16)!;
|
||||
return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 17)!;
|
||||
},
|
||||
codec: SseCodec(
|
||||
decodeSuccessData: sse_decode_unit,
|
||||
@@ -622,6 +658,32 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
argNames: [],
|
||||
);
|
||||
|
||||
@override
|
||||
void crateApiHandleMediaServicesResetWithRoute({required String routeClass}) {
|
||||
return handler.executeSync(
|
||||
SyncTask(
|
||||
callFfi: () {
|
||||
final serializer = SseSerializer(generalizedFrbRustBinding);
|
||||
sse_encode_String(routeClass, serializer);
|
||||
return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 18)!;
|
||||
},
|
||||
codec: SseCodec(
|
||||
decodeSuccessData: sse_decode_unit,
|
||||
decodeErrorData: null,
|
||||
),
|
||||
constMeta: kCrateApiHandleMediaServicesResetWithRouteConstMeta,
|
||||
argValues: [routeClass],
|
||||
apiImpl: this,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
TaskConstMeta get kCrateApiHandleMediaServicesResetWithRouteConstMeta =>
|
||||
const TaskConstMeta(
|
||||
debugName: "handle_media_services_reset_with_route",
|
||||
argNames: ["routeClass"],
|
||||
);
|
||||
|
||||
@override
|
||||
void crateApiHandleRouteChange({required BridgeAudioRoute route}) {
|
||||
return handler.executeSync(
|
||||
@@ -629,7 +691,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
callFfi: () {
|
||||
final serializer = SseSerializer(generalizedFrbRustBinding);
|
||||
sse_encode_bridge_audio_route(route, serializer);
|
||||
return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 17)!;
|
||||
return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 19)!;
|
||||
},
|
||||
codec: SseCodec(
|
||||
decodeSuccessData: sse_decode_unit,
|
||||
@@ -657,7 +719,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
pdeCallFfi(
|
||||
generalizedFrbRustBinding,
|
||||
serializer,
|
||||
funcId: 18,
|
||||
funcId: 20,
|
||||
port: port_,
|
||||
);
|
||||
},
|
||||
@@ -684,7 +746,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
pdeCallFfi(
|
||||
generalizedFrbRustBinding,
|
||||
serializer,
|
||||
funcId: 19,
|
||||
funcId: 21,
|
||||
port: port_,
|
||||
);
|
||||
},
|
||||
@@ -711,7 +773,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
pdeCallFfi(
|
||||
generalizedFrbRustBinding,
|
||||
serializer,
|
||||
funcId: 20,
|
||||
funcId: 22,
|
||||
port: port_,
|
||||
);
|
||||
},
|
||||
@@ -735,7 +797,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
SyncTask(
|
||||
callFfi: () {
|
||||
final serializer = SseSerializer(generalizedFrbRustBinding);
|
||||
return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 21)!;
|
||||
return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 23)!;
|
||||
},
|
||||
codec: SseCodec(
|
||||
decodeSuccessData: sse_decode_String,
|
||||
@@ -765,7 +827,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
pdeCallFfi(
|
||||
generalizedFrbRustBinding,
|
||||
serializer,
|
||||
funcId: 22,
|
||||
funcId: 24,
|
||||
port: port_,
|
||||
);
|
||||
},
|
||||
@@ -794,7 +856,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
pdeCallFfi(
|
||||
generalizedFrbRustBinding,
|
||||
serializer,
|
||||
funcId: 23,
|
||||
funcId: 25,
|
||||
port: port_,
|
||||
);
|
||||
},
|
||||
@@ -827,7 +889,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
pdeCallFfi(
|
||||
generalizedFrbRustBinding,
|
||||
serializer,
|
||||
funcId: 24,
|
||||
funcId: 26,
|
||||
port: port_,
|
||||
);
|
||||
},
|
||||
@@ -858,7 +920,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
pdeCallFfi(
|
||||
generalizedFrbRustBinding,
|
||||
serializer,
|
||||
funcId: 25,
|
||||
funcId: 27,
|
||||
port: port_,
|
||||
);
|
||||
},
|
||||
@@ -886,7 +948,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
pdeCallFfi(
|
||||
generalizedFrbRustBinding,
|
||||
serializer,
|
||||
funcId: 26,
|
||||
funcId: 28,
|
||||
port: port_,
|
||||
);
|
||||
},
|
||||
@@ -916,7 +978,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
pdeCallFfi(
|
||||
generalizedFrbRustBinding,
|
||||
serializer,
|
||||
funcId: 27,
|
||||
funcId: 29,
|
||||
port: port_,
|
||||
);
|
||||
},
|
||||
@@ -944,7 +1006,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
callFfi: () {
|
||||
final serializer = SseSerializer(generalizedFrbRustBinding);
|
||||
sse_encode_bridge_network_state(state, serializer);
|
||||
return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 28)!;
|
||||
return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 30)!;
|
||||
},
|
||||
codec: SseCodec(
|
||||
decodeSuccessData: sse_decode_unit,
|
||||
@@ -970,7 +1032,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
pdeCallFfi(
|
||||
generalizedFrbRustBinding,
|
||||
serializer,
|
||||
funcId: 29,
|
||||
funcId: 31,
|
||||
port: port_,
|
||||
);
|
||||
},
|
||||
@@ -998,7 +1060,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
pdeCallFfi(
|
||||
generalizedFrbRustBinding,
|
||||
serializer,
|
||||
funcId: 30,
|
||||
funcId: 32,
|
||||
port: port_,
|
||||
);
|
||||
},
|
||||
@@ -1026,7 +1088,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
pdeCallFfi(
|
||||
generalizedFrbRustBinding,
|
||||
serializer,
|
||||
funcId: 31,
|
||||
funcId: 33,
|
||||
port: port_,
|
||||
);
|
||||
},
|
||||
@@ -1058,7 +1120,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
pdeCallFfi(
|
||||
generalizedFrbRustBinding,
|
||||
serializer,
|
||||
funcId: 32,
|
||||
funcId: 34,
|
||||
port: port_,
|
||||
);
|
||||
},
|
||||
@@ -1088,7 +1150,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
pdeCallFfi(
|
||||
generalizedFrbRustBinding,
|
||||
serializer,
|
||||
funcId: 33,
|
||||
funcId: 35,
|
||||
port: port_,
|
||||
);
|
||||
},
|
||||
@@ -1106,6 +1168,36 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
TaskConstMeta get kCrateApiSetReleaseTailMsConstMeta =>
|
||||
const TaskConstMeta(debugName: "set_release_tail_ms", argNames: ["ms"]);
|
||||
|
||||
@override
|
||||
Future<void> crateApiSetTenVadModelPath({required String path}) {
|
||||
return handler.executeNormal(
|
||||
NormalTask(
|
||||
callFfi: (port_) {
|
||||
final serializer = SseSerializer(generalizedFrbRustBinding);
|
||||
sse_encode_String(path, serializer);
|
||||
pdeCallFfi(
|
||||
generalizedFrbRustBinding,
|
||||
serializer,
|
||||
funcId: 36,
|
||||
port: port_,
|
||||
);
|
||||
},
|
||||
codec: SseCodec(
|
||||
decodeSuccessData: sse_decode_unit,
|
||||
decodeErrorData: sse_decode_bridge_error,
|
||||
),
|
||||
constMeta: kCrateApiSetTenVadModelPathConstMeta,
|
||||
argValues: [path],
|
||||
apiImpl: this,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
TaskConstMeta get kCrateApiSetTenVadModelPathConstMeta => const TaskConstMeta(
|
||||
debugName: "set_ten_vad_model_path",
|
||||
argNames: ["path"],
|
||||
);
|
||||
|
||||
@override
|
||||
Future<void> crateApiSetTransmitMode({required BridgeTransmitMode mode}) {
|
||||
return handler.executeNormal(
|
||||
@@ -1116,7 +1208,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
pdeCallFfi(
|
||||
generalizedFrbRustBinding,
|
||||
serializer,
|
||||
funcId: 34,
|
||||
funcId: 37,
|
||||
port: port_,
|
||||
);
|
||||
},
|
||||
@@ -1144,7 +1236,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
pdeCallFfi(
|
||||
generalizedFrbRustBinding,
|
||||
serializer,
|
||||
funcId: 35,
|
||||
funcId: 38,
|
||||
port: port_,
|
||||
);
|
||||
},
|
||||
@@ -1171,7 +1263,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
pdeCallFfi(
|
||||
generalizedFrbRustBinding,
|
||||
serializer,
|
||||
funcId: 36,
|
||||
funcId: 39,
|
||||
port: port_,
|
||||
);
|
||||
},
|
||||
@@ -1199,7 +1291,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
pdeCallFfi(
|
||||
generalizedFrbRustBinding,
|
||||
serializer,
|
||||
funcId: 37,
|
||||
funcId: 40,
|
||||
port: port_,
|
||||
);
|
||||
},
|
||||
@@ -1231,7 +1323,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
pdeCallFfi(
|
||||
generalizedFrbRustBinding,
|
||||
serializer,
|
||||
funcId: 38,
|
||||
funcId: 41,
|
||||
port: port_,
|
||||
);
|
||||
},
|
||||
@@ -1260,7 +1352,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||
pdeCallFfi(
|
||||
generalizedFrbRustBinding,
|
||||
serializer,
|
||||
funcId: 39,
|
||||
funcId: 42,
|
||||
port: port_,
|
||||
);
|
||||
},
|
||||
|
||||
@@ -0,0 +1,313 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
|
||||
import '../services/link_trust_service.dart';
|
||||
|
||||
final _tagRe = RegExp(
|
||||
r'\[(\/?(?:b|i|u|s'
|
||||
r'|color(?:=[^\]]*)?'
|
||||
r'|size(?:=\d+)?'
|
||||
r'|url(?:=[^\]]*)?'
|
||||
r'|img(?:=[^\]]*)?'
|
||||
r'|list|\*|quote|code|center|left|right'
|
||||
r'))\]',
|
||||
caseSensitive: false,
|
||||
);
|
||||
final _colorRe = RegExp(r'color=([#\w]+)');
|
||||
final _sizeRe = RegExp(r'size=(\d+)');
|
||||
final _urlRe = RegExp(r'url=(.+)');
|
||||
final _imgRe = RegExp(r'img=(.+)');
|
||||
final _urlAutoRe = RegExp(r'(?:\[url\])?(https?://[^\s\[\]]+)(?:\[/url\])?', caseSensitive: false);
|
||||
final _closeUrlRe = RegExp(r'\[/url\]', caseSensitive: false);
|
||||
|
||||
int? _findCloseUrl(String src, int from) {
|
||||
final m = _closeUrlRe.matchAsPrefix(src, from);
|
||||
if (m != null) return from;
|
||||
final idx = src.indexOf('[/url]', from);
|
||||
if (idx >= 0) return idx;
|
||||
final idxu = src.indexOf('[/URL]', from);
|
||||
if (idxu >= 0) return idxu;
|
||||
return null;
|
||||
}
|
||||
|
||||
Color? _parseColor(String hex) {
|
||||
try {
|
||||
var h = hex.replaceFirst('#', '');
|
||||
if (h.length == 6) h = 'FF$h';
|
||||
if (h.length == 8) {
|
||||
return Color(int.parse(h, radix: 16));
|
||||
}
|
||||
} catch (_) {}
|
||||
return null;
|
||||
}
|
||||
|
||||
class BbCodeText extends StatelessWidget {
|
||||
const BbCodeText(this.text, {super.key, required this.linkTrust});
|
||||
|
||||
final String text;
|
||||
final LinkTrustService linkTrust;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (!text.contains('[') || !text.contains(']')) {
|
||||
return _plainWithAutoLinks(context, text);
|
||||
}
|
||||
return _render(context, text);
|
||||
}
|
||||
|
||||
Widget _plainWithAutoLinks(BuildContext context, String src) {
|
||||
final parts = <InlineSpan>[];
|
||||
int last = 0;
|
||||
for (final m in _urlAutoRe.allMatches(src)) {
|
||||
if (m.start > last) {
|
||||
parts.add(TextSpan(text: src.substring(last, m.start)));
|
||||
}
|
||||
final url = m.group(1)!;
|
||||
parts.add(WidgetSpan(
|
||||
alignment: PlaceholderAlignment.middle,
|
||||
child: _LinkTap(
|
||||
url: url,
|
||||
linkTrust: linkTrust,
|
||||
child: Text(
|
||||
url,
|
||||
style: const TextStyle(
|
||||
color: Colors.blue,
|
||||
decoration: TextDecoration.underline,
|
||||
),
|
||||
),
|
||||
),
|
||||
));
|
||||
last = m.end;
|
||||
}
|
||||
if (last < src.length) {
|
||||
parts.add(TextSpan(text: src.substring(last)));
|
||||
}
|
||||
if (parts.isEmpty) return Text(src);
|
||||
return Text.rich(TextSpan(children: parts));
|
||||
}
|
||||
|
||||
Widget _render(BuildContext context, String src) {
|
||||
final spans = <InlineSpan>[];
|
||||
final tags = <String>[];
|
||||
|
||||
void flush(StringBuffer buf) {
|
||||
if (buf.isEmpty) return;
|
||||
var t = buf.toString();
|
||||
buf.clear();
|
||||
|
||||
bool bold = false;
|
||||
bool italic = false;
|
||||
bool underline = false;
|
||||
bool strikethrough = false;
|
||||
Color? color;
|
||||
double? size;
|
||||
|
||||
for (final tag in tags) {
|
||||
if (tag == 'b') {
|
||||
bold = true;
|
||||
} else if (tag == 'i') {
|
||||
italic = true;
|
||||
} else if (tag == 'u') {
|
||||
underline = true;
|
||||
} else if (tag == 's') {
|
||||
strikethrough = true;
|
||||
} else if (tag.startsWith('color=')) {
|
||||
color = _parseColor(tag.substring(6));
|
||||
} else if (tag.startsWith('size=')) {
|
||||
size = double.tryParse(tag.substring(5));
|
||||
}
|
||||
}
|
||||
|
||||
spans.add(TextSpan(
|
||||
text: t,
|
||||
style: TextStyle(
|
||||
fontWeight: bold ? FontWeight.bold : null,
|
||||
fontStyle: italic ? FontStyle.italic : null,
|
||||
decoration: TextDecoration.combine([
|
||||
if (underline) TextDecoration.underline,
|
||||
if (strikethrough) TextDecoration.lineThrough,
|
||||
]),
|
||||
color: color,
|
||||
fontSize: size,
|
||||
),
|
||||
));
|
||||
}
|
||||
|
||||
final buf = StringBuffer();
|
||||
int i = 0;
|
||||
|
||||
while (i < src.length) {
|
||||
if (src[i] != '[') {
|
||||
buf.write(src[i]);
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
final m = _tagRe.matchAsPrefix(src, i);
|
||||
if (m == null) {
|
||||
buf.write(src[i]);
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
flush(buf);
|
||||
final raw = m.group(1)!.toLowerCase();
|
||||
i = m.end;
|
||||
|
||||
if (raw.startsWith('/')) {
|
||||
final closeTag = raw.substring(1);
|
||||
if (closeTag == 'url' || closeTag == 'img') {
|
||||
continue;
|
||||
}
|
||||
tags.remove(closeTag);
|
||||
continue;
|
||||
}
|
||||
|
||||
switch (raw) {
|
||||
case 'b':
|
||||
case 'i':
|
||||
case 'u':
|
||||
case 's':
|
||||
case 'list':
|
||||
case 'quote':
|
||||
case 'code':
|
||||
case 'center':
|
||||
case 'left':
|
||||
case 'right':
|
||||
tags.add(raw);
|
||||
break;
|
||||
|
||||
case '*':
|
||||
spans.add(const TextSpan(text: '\n \u2022 '));
|
||||
break;
|
||||
|
||||
default:
|
||||
if (raw.startsWith('color=') || raw.startsWith('size=')) {
|
||||
tags.add(raw);
|
||||
} else if (raw.startsWith('url=')) {
|
||||
final url = _urlRe.firstMatch(raw)?.group(1) ?? '';
|
||||
final closeIdx = _findCloseUrl(src, i);
|
||||
String inner;
|
||||
if (closeIdx != null) {
|
||||
inner = src.substring(i, closeIdx);
|
||||
i = closeIdx + 6;
|
||||
} else {
|
||||
inner = url;
|
||||
}
|
||||
spans.add(WidgetSpan(
|
||||
alignment: PlaceholderAlignment.middle,
|
||||
child: _LinkTap(
|
||||
url: url.isNotEmpty ? url : inner,
|
||||
linkTrust: linkTrust,
|
||||
child: Text(
|
||||
inner,
|
||||
style: const TextStyle(
|
||||
color: Colors.blue,
|
||||
decoration: TextDecoration.underline,
|
||||
),
|
||||
),
|
||||
),
|
||||
));
|
||||
} else if (raw.startsWith('img=')) {
|
||||
final src2 = _imgRe.firstMatch(raw)?.group(1) ?? '';
|
||||
if (src2.isNotEmpty) {
|
||||
spans.add(WidgetSpan(
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: Image.network(
|
||||
Uri.tryParse(src2)?.toString() ?? src2,
|
||||
fit: BoxFit.scaleDown,
|
||||
errorBuilder: (_, __, ___) => const SizedBox.shrink(),
|
||||
),
|
||||
),
|
||||
));
|
||||
}
|
||||
} else if (raw == 'url') {
|
||||
final closeIdx = _findCloseUrl(src, i);
|
||||
if (closeIdx != null) {
|
||||
final url = src.substring(i, closeIdx).trim();
|
||||
i = closeIdx + 6;
|
||||
spans.add(WidgetSpan(
|
||||
alignment: PlaceholderAlignment.middle,
|
||||
child: _LinkTap(
|
||||
url: url,
|
||||
linkTrust: linkTrust,
|
||||
child: Text(
|
||||
url,
|
||||
style: const TextStyle(
|
||||
color: Colors.blue,
|
||||
decoration: TextDecoration.underline,
|
||||
),
|
||||
),
|
||||
),
|
||||
));
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
flush(buf);
|
||||
|
||||
if (spans.isEmpty) return Text(src);
|
||||
return Text.rich(TextSpan(children: spans));
|
||||
}
|
||||
}
|
||||
|
||||
class _LinkTap extends StatefulWidget {
|
||||
const _LinkTap({
|
||||
required this.url,
|
||||
required this.child,
|
||||
required this.linkTrust,
|
||||
});
|
||||
|
||||
final String url;
|
||||
final Widget child;
|
||||
final LinkTrustService linkTrust;
|
||||
|
||||
@override
|
||||
State<_LinkTap> createState() => _LinkTapState();
|
||||
}
|
||||
|
||||
class _LinkTapState extends State<_LinkTap> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
widget.linkTrust.addListener(_onChanged);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
widget.linkTrust.removeListener(_onChanged);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _onChanged() => mounted ? setState(() {}) : null;
|
||||
|
||||
Future<void> _open() async {
|
||||
final uri = Uri.tryParse(widget.url);
|
||||
if (uri == null) return;
|
||||
final host = uri.host;
|
||||
if (host.isEmpty) return;
|
||||
|
||||
if (widget.linkTrust.isTrusted(host)) {
|
||||
await launchUrl(uri, mode: LaunchMode.externalApplication);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!mounted) return;
|
||||
final trust = await showLinkTrustDialog(context, host);
|
||||
if (trust == null) return;
|
||||
if (trust) {
|
||||
await widget.linkTrust.addTrustedDomain(host);
|
||||
}
|
||||
await launchUrl(uri, mode: LaunchMode.externalApplication);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GestureDetector(
|
||||
onTap: _open,
|
||||
child: widget.child,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -40,6 +40,8 @@ class VoiceStatusChip extends StatelessWidget {
|
||||
required this.audioStats,
|
||||
required this.isTouchOnly,
|
||||
required this.onTap,
|
||||
this.inputMuted = false,
|
||||
this.outputMuted = false,
|
||||
});
|
||||
|
||||
/// Current transmit mode.
|
||||
@@ -57,6 +59,12 @@ class VoiceStatusChip extends StatelessWidget {
|
||||
/// True on iOS / iPadOS / Android.
|
||||
final bool isTouchOnly;
|
||||
|
||||
/// True when local mic is muted (hard mute or permission mute).
|
||||
final bool inputMuted;
|
||||
|
||||
/// True when local speaker is muted.
|
||||
final bool outputMuted;
|
||||
|
||||
/// Open the voice details modal.
|
||||
final VoidCallback onTap;
|
||||
|
||||
@@ -95,9 +103,15 @@ class VoiceStatusChip extends StatelessWidget {
|
||||
final tailText = transmitMode == rust.BridgeTransmitMode.ptt
|
||||
? '$releaseTailMs${l10n.voiceReleaseTailHint} ${l10n.voiceReleaseTailLabel.toLowerCase()}'
|
||||
: null;
|
||||
final micText = micOn ? l10n.voiceMicOn : l10n.voiceMicOff;
|
||||
final micText = inputMuted
|
||||
? '${l10n.voiceMicOff} (muted)'
|
||||
: outputMuted
|
||||
? 'Speaker muted'
|
||||
: (micOn ? l10n.voiceMicOn : l10n.voiceMicOff);
|
||||
final line2 = tailText == null ? micText : '$tailText \u00b7 $micText';
|
||||
|
||||
final muted = inputMuted || outputMuted;
|
||||
|
||||
return Semantics(
|
||||
button: true,
|
||||
label: '${l10n.voiceSheetTitle}: $line1, $line2',
|
||||
@@ -105,17 +119,24 @@ class VoiceStatusChip extends StatelessWidget {
|
||||
child: Material(
|
||||
type: MaterialType.transparency,
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
onTap: () {
|
||||
HapticFeedback.lightImpact();
|
||||
onTap();
|
||||
},
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: ExcludeSemantics(
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.surfaceContainerHigh,
|
||||
color: muted
|
||||
? theme.colorScheme.errorContainer.withValues(alpha: 0.35)
|
||||
: theme.colorScheme.surfaceContainerHigh,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: theme.colorScheme.outlineVariant,
|
||||
width: 0.5,
|
||||
color: muted
|
||||
? theme.colorScheme.error
|
||||
: theme.colorScheme.outlineVariant,
|
||||
width: muted ? 1.5 : 0.5,
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
@@ -394,6 +415,7 @@ class _VoiceSheetBodyState extends State<_VoiceSheetBody> {
|
||||
late bool _aecEnabled;
|
||||
late bool _agcEnabled;
|
||||
late bool _hpfEnabled;
|
||||
late bool _preferHardware;
|
||||
late rust.BridgeVadBackend _vadBackend;
|
||||
|
||||
@override
|
||||
@@ -404,6 +426,7 @@ class _VoiceSheetBodyState extends State<_VoiceSheetBody> {
|
||||
_aecEnabled = c.aec != rust.BridgeEffectOwner.off;
|
||||
_agcEnabled = c.agc != rust.BridgeEffectOwner.off;
|
||||
_hpfEnabled = c.hpfEnabled;
|
||||
_preferHardware = c.aec == rust.BridgeEffectOwner.platform;
|
||||
_vadBackend = c.vadBackend == rust.BridgeVadBackend.disabled
|
||||
? rust.BridgeVadBackend.webrtcVad
|
||||
: c.vadBackend;
|
||||
@@ -440,23 +463,51 @@ class _VoiceSheetBodyState extends State<_VoiceSheetBody> {
|
||||
final c = widget.initialAudioConfig;
|
||||
final isSonora =
|
||||
c.iosMode == rust.BridgeIosVoiceProcessingMode.sonoraExperimental;
|
||||
// VPIO owns enabled effects on the default path. Sonora owns them only in
|
||||
// the experimental raw path.
|
||||
final isAndroid = Platform.isAndroid;
|
||||
|
||||
if (isAndroid) {
|
||||
final owner = _preferHardware
|
||||
? rust.BridgeEffectOwner.platform
|
||||
: rust.BridgeEffectOwner.webrtcApm;
|
||||
return rust.BridgeAudioProcessingConfig(
|
||||
route: c.route,
|
||||
iosMode: c.iosMode,
|
||||
processingBackend: _preferHardware
|
||||
? rust.BridgeAudioBackend.platformVoiceProcessing
|
||||
: rust.BridgeAudioBackend.webrtcApm,
|
||||
vadBackend: _vadBackend == rust.BridgeVadBackend.disabled
|
||||
? rust.BridgeVadBackend.webrtcVad
|
||||
: _vadBackend,
|
||||
aec: _aecEnabled ? owner : rust.BridgeEffectOwner.off,
|
||||
ns: _nsEnabled ? owner : rust.BridgeEffectOwner.off,
|
||||
agc: _agcEnabled ? owner : rust.BridgeEffectOwner.off,
|
||||
hpfEnabled: _hpfEnabled,
|
||||
limiterEnabled: c.limiterEnabled,
|
||||
vadHangoverMs: c.vadHangoverMs,
|
||||
vadPreRollMs: c.vadPreRollMs,
|
||||
vadMinTxMs: c.vadMinTxMs,
|
||||
debugWavDumpEnabled: c.debugWavDumpEnabled,
|
||||
);
|
||||
}
|
||||
|
||||
// iOS / macOS: VPIO vs Sonora paths.
|
||||
// VPIO owns enabled effects on the default path. The experimental raw path
|
||||
// delegates app-side processing to WebRTC APM.
|
||||
final aecOwner = isSonora
|
||||
? (_aecEnabled
|
||||
? rust.BridgeEffectOwner.sonora
|
||||
? rust.BridgeEffectOwner.webrtcApm
|
||||
: rust.BridgeEffectOwner.off)
|
||||
: rust.BridgeEffectOwner.platform;
|
||||
final nsOwner = isSonora
|
||||
? (_nsEnabled
|
||||
? rust.BridgeEffectOwner.sonora
|
||||
? rust.BridgeEffectOwner.webrtcApm
|
||||
: rust.BridgeEffectOwner.off)
|
||||
: (_nsEnabled
|
||||
? rust.BridgeEffectOwner.platform
|
||||
: rust.BridgeEffectOwner.off);
|
||||
final agcOwner = isSonora
|
||||
? (_agcEnabled
|
||||
? rust.BridgeEffectOwner.sonora
|
||||
? rust.BridgeEffectOwner.webrtcApm
|
||||
: rust.BridgeEffectOwner.off)
|
||||
: (_agcEnabled
|
||||
? rust.BridgeEffectOwner.platform
|
||||
@@ -631,6 +682,22 @@ class _VoiceSheetBodyState extends State<_VoiceSheetBody> {
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
|
||||
// Android: hardware (JNI) vs software (WebRTC APM)
|
||||
if (Platform.isAndroid) ...[
|
||||
_AudioToggleRow(
|
||||
label: 'Prefer hardware effects',
|
||||
subtitle: _preferHardware
|
||||
? 'Try JNI hardware · software fallback'
|
||||
: 'Software WebRTC AEC3 · NS · AGC2',
|
||||
value: _preferHardware,
|
||||
onChanged: (v) {
|
||||
setState(() => _preferHardware = v);
|
||||
_notifyAudioConfig();
|
||||
},
|
||||
),
|
||||
],
|
||||
|
||||
_AudioToggleRow(
|
||||
label: 'Noise suppression',
|
||||
subtitle: 'Wiener filter',
|
||||
@@ -642,25 +709,40 @@ class _VoiceSheetBodyState extends State<_VoiceSheetBody> {
|
||||
),
|
||||
_AudioToggleRow(
|
||||
label: 'Echo cancellation',
|
||||
subtitle:
|
||||
widget.initialAudioConfig.iosMode ==
|
||||
rust.BridgeIosVoiceProcessingMode.platformVoiceProcessing
|
||||
? 'Always on · managed by platform VPIO'
|
||||
: 'AEC3 adaptive filter · 80 ms tail',
|
||||
subtitle: () {
|
||||
if (Platform.isAndroid) {
|
||||
return 'WebRTC AEC3 · adaptive filter';
|
||||
}
|
||||
return widget.initialAudioConfig.iosMode ==
|
||||
rust.BridgeIosVoiceProcessingMode.platformVoiceProcessing
|
||||
? 'Always on · managed by platform VPIO'
|
||||
: 'AEC3 adaptive filter · 80 ms tail';
|
||||
}(),
|
||||
value:
|
||||
widget.initialAudioConfig.iosMode ==
|
||||
rust.BridgeIosVoiceProcessingMode.platformVoiceProcessing
|
||||
? true // always on in VPIO
|
||||
: _aecEnabled,
|
||||
() {
|
||||
if (Platform.isAndroid) return _aecEnabled;
|
||||
return widget.initialAudioConfig.iosMode ==
|
||||
rust.BridgeIosVoiceProcessingMode.platformVoiceProcessing
|
||||
? true
|
||||
: _aecEnabled;
|
||||
}(),
|
||||
// AEC is always on in VPIO — disable the toggle.
|
||||
onChanged:
|
||||
widget.initialAudioConfig.iosMode ==
|
||||
rust.BridgeIosVoiceProcessingMode.platformVoiceProcessing
|
||||
? null
|
||||
: (v) {
|
||||
setState(() => _aecEnabled = v);
|
||||
_notifyAudioConfig();
|
||||
},
|
||||
// On Android, AEC is user-selectable.
|
||||
onChanged: () {
|
||||
if (Platform.isAndroid) {
|
||||
return (v) {
|
||||
setState(() => _aecEnabled = v);
|
||||
_notifyAudioConfig();
|
||||
};
|
||||
}
|
||||
return widget.initialAudioConfig.iosMode ==
|
||||
rust.BridgeIosVoiceProcessingMode.platformVoiceProcessing
|
||||
? null
|
||||
: (v) {
|
||||
setState(() => _aecEnabled = v);
|
||||
_notifyAudioConfig();
|
||||
};
|
||||
}(),
|
||||
),
|
||||
_AudioToggleRow(
|
||||
label: 'Auto gain control',
|
||||
|
||||
@@ -18,6 +18,11 @@ import '../l10n/generated/app_localizations.dart';
|
||||
import 'voice_platform.dart';
|
||||
import '../src/rust/api.dart' as rust;
|
||||
|
||||
bool get _isAndroid {
|
||||
if (kIsWeb) return false;
|
||||
return Platform.isAndroid;
|
||||
}
|
||||
|
||||
bool get _isIos {
|
||||
if (kIsWeb) return false;
|
||||
return Platform.isIOS;
|
||||
@@ -68,6 +73,7 @@ class _VoiceSettingsDialogState extends State<VoiceSettingsDialog> {
|
||||
late rust.BridgeVadBackend _vadBackend;
|
||||
late rust.BridgeIosVoiceProcessingMode _iosMode;
|
||||
late bool _debugWavDump;
|
||||
late bool _preferHardware; // Android only: try JNI hardware effects
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -86,29 +92,59 @@ class _VoiceSettingsDialogState extends State<VoiceSettingsDialog> {
|
||||
: c.vadBackend;
|
||||
_iosMode = c.iosMode;
|
||||
_debugWavDump = c.debugWavDumpEnabled;
|
||||
_preferHardware = c.aec == rust.BridgeEffectOwner.platform
|
||||
|| c.ns == rust.BridgeEffectOwner.platform
|
||||
|| c.agc == rust.BridgeEffectOwner.platform;
|
||||
}
|
||||
|
||||
rust.BridgeAudioProcessingConfig _buildConfig() {
|
||||
final c = widget.initialAudioConfig;
|
||||
final isSonora =
|
||||
_iosMode == rust.BridgeIosVoiceProcessingMode.sonoraExperimental;
|
||||
// In VPIO mode, enabled effects are platform-owned. Sonora ownership is
|
||||
// reserved for the experimental raw path so config validation stays honest.
|
||||
|
||||
if (_isAndroid) {
|
||||
final owner = _preferHardware
|
||||
? rust.BridgeEffectOwner.platform
|
||||
: rust.BridgeEffectOwner.webrtcApm;
|
||||
return rust.BridgeAudioProcessingConfig(
|
||||
route: c.route,
|
||||
iosMode: _iosMode,
|
||||
processingBackend: _preferHardware
|
||||
? rust.BridgeAudioBackend.platformVoiceProcessing
|
||||
: rust.BridgeAudioBackend.webrtcApm,
|
||||
vadBackend: _vadBackend == rust.BridgeVadBackend.disabled
|
||||
? rust.BridgeVadBackend.webrtcVad
|
||||
: _vadBackend,
|
||||
aec: _aecEnabled ? owner : rust.BridgeEffectOwner.off,
|
||||
ns: _nsEnabled ? owner : rust.BridgeEffectOwner.off,
|
||||
agc: _agcEnabled ? owner : rust.BridgeEffectOwner.off,
|
||||
hpfEnabled: _hpfEnabled,
|
||||
limiterEnabled: _limiterEnabled,
|
||||
vadHangoverMs: c.vadHangoverMs,
|
||||
vadPreRollMs: c.vadPreRollMs,
|
||||
vadMinTxMs: c.vadMinTxMs,
|
||||
debugWavDumpEnabled: _debugWavDump,
|
||||
);
|
||||
}
|
||||
|
||||
// iOS / macOS: VPIO vs Sonora paths.
|
||||
// In VPIO mode, enabled effects are platform-owned. The experimental raw
|
||||
// path uses WebRTC APM ownership so config validation stays honest.
|
||||
final aecOwner = isSonora
|
||||
? (_aecEnabled
|
||||
? rust.BridgeEffectOwner.sonora
|
||||
? rust.BridgeEffectOwner.webrtcApm
|
||||
: rust.BridgeEffectOwner.off)
|
||||
: rust.BridgeEffectOwner.platform; // VPIO always owns AEC
|
||||
final nsOwner = isSonora
|
||||
? (_nsEnabled
|
||||
? rust.BridgeEffectOwner.sonora
|
||||
? rust.BridgeEffectOwner.webrtcApm
|
||||
: rust.BridgeEffectOwner.off)
|
||||
: (_nsEnabled
|
||||
? rust.BridgeEffectOwner.platform
|
||||
: rust.BridgeEffectOwner.off);
|
||||
final agcOwner = isSonora
|
||||
? (_agcEnabled
|
||||
? rust.BridgeEffectOwner.sonora
|
||||
? rust.BridgeEffectOwner.webrtcApm
|
||||
: rust.BridgeEffectOwner.off)
|
||||
: (_agcEnabled
|
||||
? rust.BridgeEffectOwner.platform
|
||||
@@ -120,7 +156,7 @@ class _VoiceSettingsDialogState extends State<VoiceSettingsDialog> {
|
||||
route: c.route,
|
||||
iosMode: _iosMode,
|
||||
processingBackend: isSonora
|
||||
? rust.BridgeAudioBackend.sonora
|
||||
? rust.BridgeAudioBackend.webrtcApm
|
||||
: rust.BridgeAudioBackend.platformVoiceProcessing,
|
||||
vadBackend: vadBackend,
|
||||
aec: aecOwner,
|
||||
@@ -244,6 +280,30 @@ class _VoiceSettingsDialogState extends State<VoiceSettingsDialog> {
|
||||
const SizedBox(height: 4),
|
||||
],
|
||||
|
||||
// Android HW/SW selector
|
||||
if (_isAndroid) ...[
|
||||
_subHeader(theme, 'Processing backend'),
|
||||
_radioTile<bool>(
|
||||
value: true,
|
||||
groupValue: _preferHardware,
|
||||
title: const Text('Platform (auto)'),
|
||||
subtitle: _tileSubtitle(
|
||||
'Try hardware JNI effects · software fallback',
|
||||
),
|
||||
onSelected: (v) => setState(() => _preferHardware = v),
|
||||
),
|
||||
_radioTile<bool>(
|
||||
value: false,
|
||||
groupValue: _preferHardware,
|
||||
title: const Text('WebRTC APM'),
|
||||
subtitle: _tileSubtitle(
|
||||
'Software AEC3 · NS · AGC2',
|
||||
),
|
||||
onSelected: (v) => setState(() => _preferHardware = v),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
],
|
||||
|
||||
// DSP toggles
|
||||
_subHeader(theme, 'DSP stages'),
|
||||
_switchTile(
|
||||
@@ -254,12 +314,14 @@ class _VoiceSettingsDialogState extends State<VoiceSettingsDialog> {
|
||||
),
|
||||
_switchTile(
|
||||
title: 'Echo cancellation (AEC3)',
|
||||
subtitle: platformVpio
|
||||
? 'Managed by platform VPIO'
|
||||
: 'Adaptive NLMS · 80 ms tail',
|
||||
subtitle: _isAndroid
|
||||
? 'WebRTC AEC3 · adaptive filter'
|
||||
: platformVpio
|
||||
? 'Managed by platform VPIO'
|
||||
: 'Adaptive NLMS · 80 ms tail',
|
||||
value: _aecEnabled,
|
||||
// AEC is always on in VPIO mode — disable the toggle.
|
||||
onSelected: platformVpio ? null : (v) => _aecEnabled = v,
|
||||
onSelected: (_isAndroid || !platformVpio) ? (v) => _aecEnabled = v : null,
|
||||
),
|
||||
_switchTile(
|
||||
title: 'Auto gain control (AGC2)',
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
#
|
||||
|
||||
list(APPEND FLUTTER_PLUGIN_LIST
|
||||
url_launcher_linux
|
||||
)
|
||||
|
||||
list(APPEND FLUTTER_FFI_PLUGIN_LIST
|
||||
|
||||
@@ -246,6 +246,14 @@ packages:
|
||||
description: flutter
|
||||
source: sdk
|
||||
version: "0.0.0"
|
||||
flutter_foreground_task:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: flutter_foreground_task
|
||||
sha256: fc5c01a5e1b8f7bb51d0c737714f0c50440dbdf1aeddc5f8cbba313aa6fd4856
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "9.2.2"
|
||||
flutter_lints:
|
||||
dependency: "direct dev"
|
||||
description:
|
||||
@@ -629,6 +637,62 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.28.0"
|
||||
shared_preferences:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: shared_preferences
|
||||
sha256: c3025c5534b01739267eb7d76959bbc25a6d10f6988e1c2a3036940133dd10bf
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.5.5"
|
||||
shared_preferences_android:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: shared_preferences_android
|
||||
sha256: e8d4762b1e2e8578fc4d0fd548cebf24afd24f49719c08974df92834565e2c53
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.4.23"
|
||||
shared_preferences_foundation:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: shared_preferences_foundation
|
||||
sha256: "4e7eaffc2b17ba398759f1151415869a34771ba11ebbccd1b0145472a619a64f"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.5.6"
|
||||
shared_preferences_linux:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: shared_preferences_linux
|
||||
sha256: "580abfd40f415611503cae30adf626e6656dfb2f0cee8f465ece7b6defb40f2f"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.4.1"
|
||||
shared_preferences_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: shared_preferences_platform_interface
|
||||
sha256: "649dc798a33931919ea356c4305c2d1f81619ea6e92244070b520187b5140ef9"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.4.2"
|
||||
shared_preferences_web:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: shared_preferences_web
|
||||
sha256: c49bd060261c9a3f0ff445892695d6212ff603ef3115edbb448509d407600019
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.4.3"
|
||||
shared_preferences_windows:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: shared_preferences_windows
|
||||
sha256: "94ef0f72b2d71bc3e700e025db3710911bd51a71cefb65cc609dd0d9a982e3c1"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.4.1"
|
||||
shelf:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -722,6 +786,70 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.4.0"
|
||||
url_launcher:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: url_launcher
|
||||
sha256: f6a7e5c4835bb4e3026a04793a4199ca2d14c739ec378fdfe23fc8075d0439f8
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.3.2"
|
||||
url_launcher_android:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: url_launcher_android
|
||||
sha256: "17bc677f0b301615530dd1d67e0a9828cafa2d0b6b6eae4cd3679b7eac4a273c"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.3.30"
|
||||
url_launcher_ios:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: url_launcher_ios
|
||||
sha256: "580fe5dfb51671ae38191d316e027f6b76272b026370708c2d898799750a02b0"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.4.1"
|
||||
url_launcher_linux:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: url_launcher_linux
|
||||
sha256: d5e14138b3bc193a0f63c10a53c94b91d399df0512b1f29b94a043db7482384a
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.2.2"
|
||||
url_launcher_macos:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: url_launcher_macos
|
||||
sha256: "368adf46f71ad3c21b8f06614adb38346f193f3a59ba8fe9a2fd74133070ba18"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.2.5"
|
||||
url_launcher_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: url_launcher_platform_interface
|
||||
sha256: "552f8a1e663569be95a8190206a38187b531910283c3e982193e4f2733f01029"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.3.2"
|
||||
url_launcher_web:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: url_launcher_web
|
||||
sha256: "85c81589622fbc87c1c683aaea164d3604a7777495a79d91e39ffcdec39ddb34"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.4.3"
|
||||
url_launcher_windows:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: url_launcher_windows
|
||||
sha256: "712c70ab1b99744ff066053cbe3e80c73332b38d46e5e945c98689b2e66fc15f"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.1.5"
|
||||
vector_math:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
||||
@@ -77,6 +77,9 @@ dependencies:
|
||||
# Touch-only PTT feedback for mobile voice UX (P0 voice basics,
|
||||
# DEC-003 iOS 13 floor; haptic_kit supports iOS 12+).
|
||||
haptic_kit: ^1.0.0
|
||||
flutter_foreground_task: ^9.2.2
|
||||
url_launcher: ^6.3.2
|
||||
shared_preferences: ^2.5.5
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
list(APPEND FLUTTER_PLUGIN_LIST
|
||||
connectivity_plus
|
||||
url_launcher_windows
|
||||
)
|
||||
|
||||
list(APPEND FLUTTER_FFI_PLUGIN_LIST
|
||||
|
||||
Reference in New Issue
Block a user