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:
Edison Jwa
2026-05-22 09:29:57 +09:00
parent 6af4ecab0f
commit bf284018e6
37 changed files with 3676 additions and 703 deletions
@@ -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 = "../.."
}
+5
View File
@@ -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
@@ -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
}
}
}
@@ -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"
}
}
@@ -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)"
}
}
}
@@ -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
@@ -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"
+134 -17
View File
@@ -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'),
),
],
),
),
);
}
+28 -46
View File
@@ -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
+128
View File
@@ -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:
+3
View File
@@ -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