feat: integrate chat voice and diagnostics client

This commit is contained in:
Edison Jwa
2026-05-23 06:51:55 +09:00
parent 7e28791ec2
commit 7d5d8c2c90
93 changed files with 10273 additions and 3347 deletions
Generated
+43 -5
View File
@@ -432,7 +432,7 @@ dependencies = [
"oboe",
"ort",
"rand 0.8.6",
"reqwest",
"rustfft",
"sdl2",
"serde_json",
"sonora",
@@ -452,7 +452,6 @@ version = "0.2.0-beta.1"
dependencies = [
"chanora_audio",
"chanora_core",
"chanora_protocol",
"flutter_rust_bridge",
"jni 0.21.1",
"log",
@@ -501,6 +500,7 @@ dependencies = [
"thiserror 2.0.18",
"tokio",
"tracing",
"ts-bookkeeping",
"tsclientlib",
"tsproto-packets",
"tsproto-types",
@@ -511,7 +511,6 @@ name = "chanora_state"
version = "0.2.0-beta.1"
dependencies = [
"thiserror 2.0.18",
"tracing",
]
[[package]]
@@ -2561,7 +2560,7 @@ dependencies = [
[[package]]
name = "oboe"
version = "0.6.2"
version = "0.6.3"
dependencies = [
"num-derive",
"num-traits",
@@ -2570,7 +2569,7 @@ dependencies = [
[[package]]
name = "oboe-sys"
version = "0.6.2"
version = "0.6.3"
dependencies = [
"cc",
]
@@ -2872,6 +2871,15 @@ dependencies = [
"syn",
]
[[package]]
name = "primal-check"
version = "0.3.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc0d895b311e3af9902528fbb8f928688abbd95872819320517cc24ca6b2bd08"
dependencies = [
"num-integer",
]
[[package]]
name = "primeorder"
version = "0.13.6"
@@ -3259,6 +3267,20 @@ dependencies = [
"semver",
]
[[package]]
name = "rustfft"
version = "6.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "21db5f9893e91f41798c88680037dba611ca6674703c1a18601b01a72c8adb89"
dependencies = [
"num-complex",
"num-integer",
"num-traits",
"primal-check",
"strength_reduce",
"transpose",
]
[[package]]
name = "rustix"
version = "1.1.4"
@@ -3727,6 +3749,12 @@ version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
[[package]]
name = "strength_reduce"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fe895eb47f22e2ddd4dabc02bce419d2e643c8e3b585c78158b349195bc24d82"
[[package]]
name = "subtle"
version = "2.6.1"
@@ -4192,6 +4220,16 @@ dependencies = [
"tracing-log",
]
[[package]]
name = "transpose"
version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1ad61aed86bc3faea4300c7aee358b4c6d0c8d6ccc36524c96e4c92ccf26e77e"
dependencies = [
"num-integer",
"strength_reduce",
]
[[package]]
name = "try-lock"
version = "0.2.5"
@@ -69,8 +69,8 @@ android {
// Per-ABI delivery is handled by the AAB bundle splits below
// (SDD-109 item 2) rather than a fat APK.
ndk {
// DEC-032 RESOLVED (2026-05-18): canonical three-ABI set
// restored after the audiopus_sys + cmake-rs + NDK toolchain-file
// DEC-032 RESOLVED (2026-05-18): canonical two-ABI set
// (arm64-v8a + x86_64) restored after the audiopus_sys
// ANDROID_ABI propagation gap was closed by the workspace
// [patch.crates-io] override pinning `cmake` to the fork
// carrying cmake-rs PR #257 (forwards ANDROID_ABI /
@@ -138,9 +138,10 @@ android {
}
// SDD-109 item 2: Android App Bundle (.aab) split configuration.
// Combined with the SDD-073 abiFilters set, this yields three
// native splits (arm64-v8a, armeabi-v7a, x86_64), per-language
// resource delivery, and per-density resource delivery.
// Combined with the SDD-073 abiFilters set, this yields two
// native splits (arm64-v8a, x86_64), per-language resource
// delivery, and per-density resource delivery. Each device
// receives only its matching ABI .so — no cross-ABI bundling.
bundle {
language {
enableSplit = true
@@ -221,7 +222,6 @@ val chanoraAndroidMinSdk: Provider<String> =
// for the per-ABI copy and for the inputs/outputs declaration.
val abiToRustTriple: Map<String, String> = mapOf(
"arm64-v8a" to "aarch64-linux-android",
"armeabi-v7a" to "armv7-linux-androideabi",
"x86_64" to "x86_64-linux-android",
)
@@ -278,7 +278,7 @@ val checkRustBridgeToolchain = tasks.register("checkRustBridgeToolchain") {
if (missing.isNotEmpty()) {
throw GradleException(
"[SDD-118] Missing Android Rust target(s): ${missing.joinToString(", ")}. " +
"Install: rustup target add aarch64-linux-android armv7-linux-androideabi x86_64-linux-android"
"Install: rustup target add aarch64-linux-android x86_64-linux-android"
)
}
}
@@ -312,6 +312,15 @@ fun registerCargoNdkBuildTask(profile: String): TaskProvider<*> {
.takeUnless { it.isNullOrBlank() }
?: android.ndkDirectory.absolutePath
val cmakeToolchainFile = "$effectiveNdkPath/build/cmake/android.toolchain.cmake"
val homeDir = System.getenv("HOME") ?: System.getProperty("user.home")
val rustupBinDir = listOf(
"$homeDir/.cargo/bin",
"/opt/homebrew/opt/rustup/bin",
"/usr/local/opt/rustup/bin"
).firstOrNull { File(it).resolve("cargo").exists() }
val rustupHome = System.getenv("RUSTUP_HOME") ?: "$homeDir/.rustup"
val cargoHome = System.getenv("CARGO_HOME") ?: "$homeDir/.cargo"
val rustupToolchain = System.getenv("RUSTUP_TOOLCHAIN") ?: "stable-aarch64-apple-darwin"
// SDD-118 item 13 (corrected): per-ABI Exec sub-tasks. Each runs an
// isolated `cargo ndk -t <abi> ... -- build ...` so ANDROID_ABI is set
@@ -361,15 +370,18 @@ fun registerCargoNdkBuildTask(profile: String): TaskProvider<*> {
// Any CARGO_TARGET_* env vars set by an outer build wrapper
val cleanEnv = buildMap<String, String> {
// Pass-through from the calling environment, only the safe set.
System.getenv("PATH")?.let { put("PATH", it) }
System.getenv("HOME")?.let { put("HOME", it) }
System.getenv("PATH")?.let { path ->
put("PATH", listOfNotNull(rustupBinDir, path).joinToString(":"))
}
put("HOME", homeDir)
System.getenv("USER")?.let { put("USER", it) }
System.getenv("TMPDIR")?.let { put("TMPDIR", it) }
System.getenv("TEMP")?.let { put("TEMP", it) }
System.getenv("LANG")?.let { put("LANG", it) }
System.getenv("LC_ALL")?.let { put("LC_ALL", it) }
System.getenv("CARGO_HOME")?.let { put("CARGO_HOME", it) }
System.getenv("RUSTUP_HOME")?.let { put("RUSTUP_HOME", it) }
put("CARGO_HOME", cargoHome)
put("RUSTUP_HOME", rustupHome)
put("RUSTUP_TOOLCHAIN", rustupToolchain)
System.getenv("JAVA_HOME")?.let { put("JAVA_HOME", it) }
// Per-task explicit settings (override any pass-through).
// SDD-118 item 5: audiopus_sys needs libopus built from source
@@ -444,7 +456,6 @@ val buildRustBridgeRelease = registerCargoNdkBuildTask("release")
// $ANDROID_NDK_HOME/toolchains/llvm/prebuilt/linux-x86_64/sysroot/usr/lib/.
val abiToNdkSysrootTriple: Map<String, String> = mapOf(
"arm64-v8a" to "aarch64-linux-android",
"armeabi-v7a" to "arm-linux-androideabi",
"x86_64" to "x86_64-linux-android",
)
fun registerJniLibsCopyTask(profile: String): TaskProvider<Copy> {
+10 -13
View File
@@ -11,8 +11,8 @@
# the app module these are looked up by signature from C/Rust.
# 3. The Android voice foreground service (SDD-107) referenced from
# the manifest by FQN and from JNI via static start/stop helpers.
# 4. The Android audio mode controller (SDD-108) referenced from
# JNI via GlobalRef.
# 4. The Android audio mode controller (SDD-108) audio mode is
# managed in Rust via direct JNI (engine.rs), not a Kotlin class.
# 5. Kotlin metadata required by FRB-generated bindings (SDD-079).
# --- 1. chanora_bridge / flutter_rust_bridge JNI surface (SDD-105) -----------
@@ -46,21 +46,18 @@
}
# --- 3. AndroidVoiceForegroundService (SDD-107) ------------------------------
# The service is referenced by FQN from AndroidManifest.xml and by JNI from
# the Rust audio engine (start/stop helpers per SDD-107 item 10). Keep the
# class and its public/static members.
-keep class app.chanora.chanora_flutter.AndroidVoiceForegroundService { *; }
# Covered by the JNI-surface keep block above.
# --- 4. AndroidAudioModeController (SDD-108) ---------------------------------
# Referenced from Rust via GlobalRef + static method invocation.
# Wave 2B-2 owns the class; keep rule is forward-looking but harmless if
# the class does not yet exist (R8 ignores keep rules for missing classes).
-keep class app.chanora.chanora_flutter.AndroidAudioModeController { *; }
# --- 4. Android Audio Mode Controller (SDD-108) -------------------------
# SDD-108 in-call audio mode (AudioManager.setMode) is implemented in the
# Rust audio engine (crates/chanora_audio/src/engine.rs) via direct JNI,
# not through a Kotlin controller class. No keep rule is needed; the
# engine.rs android_set_audio_mode / android_get_audio_mode helpers
# resolve AudioManager at runtime via ndk_context.
# --- 5. Kotlin metadata + serialisation (SDD-079) ----------------------------
-keepattributes *Annotation*, Signature, InnerClasses, EnclosingMethod
-keep class kotlin.Metadata { *; }
# Defer to Flutter's own keep rules for the embedding layer; the Flutter
# Gradle plugin contributes those automatically. If a future SDD revision
# disables R8 entirely, this file can be reduced to a single TODO comment.
# Gradle plugin contributes those automatically.
@@ -5,6 +5,18 @@
protocol layer to dial a TeamSpeak-compatible server over UDP. -->
<uses-permission android:name="android.permission.INTERNET" />
<!-- SRS-100: Network diagnostics. Allows the core to query active
network type and state for the diagnostic export. -->
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<!-- SDD-115: Prevent CPU sleep during active voice sessions.
Keeps the audio pipeline running when the screen locks. -->
<uses-permission android:name="android.permission.WAKE_LOCK" />
<!-- SRS-025: Haptic feedback on PTT key press. Enables
VibrationEffect for transmit-mode tactile confirmation. -->
<uses-permission android:name="android.permission.VIBRATE" />
<!-- SDD-trace: SDD-106 AndroidPermissionRequester.
Required for the audio engine (chanora_audio) to open the
capture stream for voice transmission. The runtime grant
@@ -56,7 +68,8 @@
<application
android:label="Chanora"
android:name="app.chanora.chanora_flutter.ChanoraApplication"
android:icon="@mipmap/ic_launcher">
android:icon="@mipmap/ic_launcher"
android:enableOnBackInvokedCallback="true">
<activity
android:name=".MainActivity"
android:exported="true"
@@ -324,13 +324,11 @@ class AndroidPermissionRequester {
callback: ((PermissionState) -> Unit)?,
) {
callback?.invoke(state)
// The stateChangeListener is wired by MainActivity to invoke
// METHOD_PERMISSION_STATE_CHANGED on ANDROID_PERMISSIONS, which
// publishes BridgeEvent::PermissionState to the bridge event
// stream. See MainActivity.configureFlutterEngine (SDD-106 §5).
stateChangeListener?.invoke(permission, state)
// TODO(Wave 2B follow-up): wire this into a Flutter MethodChannel
// named MethodChannels.ANDROID_PERMISSIONS, invoking method
// MethodChannels.METHOD_PERMISSION_STATE_CHANGED. The Dart
// handler then publishes the corresponding
// BridgeEvent::PermissionState onto the bridge event stream
// (SDD-106 §5).
}
/**
@@ -71,24 +71,9 @@ class AndroidVoiceForegroundService : Service() {
*/
private const val CHANNEL_ID = "chanora.voice.session"
/**
* Notification channel user-visible name.
*
* TODO(localization): SDD-107 §4 requires this to be sourced
* from a product string resource. Hard-coded here for the
* Wave 2B implementation slice; localised strings land
* alongside the broader Android string-resource pass.
*/
private const val CHANNEL_NAME = "Voice session"
/**
* Notification channel description (product copy only, no
* server-supplied content per SDD-107 §5 privacy note).
*
* TODO(localization): see [CHANNEL_NAME].
*/
private const val CHANNEL_DESCRIPTION =
"Shown while a Chanora voice session is active."
// SDD-107 §4: notification strings are sourced from
// res/values/strings.xml and accessed via the service context
// in instance methods below (ensureChannel, buildNotification).
/**
* Stable notification id ("CHAN") per SDD-107 §5. Must remain
@@ -298,10 +283,10 @@ class AndroidVoiceForegroundService : Service() {
if (existing != null) return
val channel = NotificationChannel(
CHANNEL_ID,
CHANNEL_NAME,
getString(R.string.voice_session_channel_name),
NotificationManager.IMPORTANCE_LOW,
).apply {
description = CHANNEL_DESCRIPTION
description = getString(R.string.voice_session_channel_description)
setShowBadge(false)
}
nm.createNotificationChannel(channel)
@@ -329,14 +314,9 @@ class AndroidVoiceForegroundService : Service() {
}
val builder = NotificationCompat.Builder(this, CHANNEL_ID)
.setSmallIcon(android.R.drawable.stat_sys_speakerphone)
// TODO(SDD-107 §5): replace stat_sys_speakerphone with the
// product icon `ic_chanora_voice` once the drawable lands
// in res/drawable. Using a platform-provided icon as the
// interim placeholder keeps the build green without
// touching res/* (out of this slice's file set).
.setContentTitle("Chanora — Voice session active")
.setContentText("Microphone may be in use.")
.setSmallIcon(R.mipmap.ic_launcher)
.setContentTitle(getString(R.string.voice_session_notification_title))
.setContentText(getString(R.string.voice_session_notification_text))
.setOngoing(true)
.setCategory(NotificationCompat.CATEGORY_CALL)
.setPriority(NotificationCompat.PRIORITY_LOW)
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- SDD-107 §4: Chinese (Simplified) localized notification strings.
Product-owned copy; no server-supplied content per SDD-107 §5. -->
<string name="voice_session_channel_name">语音会话</string>
<string name="voice_session_channel_description">Chanora 语音会话处于活动状态时显示。</string>
<string name="voice_session_notification_title">Chanora — 语音会话进行中</string>
<string name="voice_session_notification_text">麦克风可能正在使用中。</string>
</resources>
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- SDD-107 §4: Voice session foreground service notification strings.
These are product-owned strings; server-supplied content is never
placed in notification copy per SDD-107 §5 privacy note. -->
<string name="voice_session_channel_name">Voice session</string>
<string name="voice_session_channel_description">Shown while a Chanora voice session is active.</string>
<string name="voice_session_notification_title">Chanora — Voice session active</string>
<string name="voice_session_notification_text">Microphone may be in use.</string>
</resources>
@@ -0,0 +1,185 @@
// SPDX-License-Identifier: Apache-2.0
// Chanora design tokens for connection, voice, and latency states.
// Maps abstract server/voice/network state to Material 3 colour, icon,
// and label affordances.
import 'package:flutter/material.dart';
/// Tokenised visual mapping for a connection state.
class ConnectionTokens {
final Color color;
final Color? background;
final IconData icon;
final String label;
const ConnectionTokens({
required this.color,
required this.icon,
required this.label,
this.background,
});
static ConnectionTokens disconnected(ColorScheme cs) => ConnectionTokens(
color: cs.onSurfaceVariant,
background: cs.surfaceContainerHighest,
icon: Icons.cloud_off,
label: 'Disconnected',
);
static ConnectionTokens connecting(ColorScheme cs) => ConnectionTokens(
color: cs.primary,
background: cs.primaryContainer,
icon: Icons.sync,
label: 'Connecting',
);
static ConnectionTokens synchronizing(ColorScheme cs) => ConnectionTokens(
color: cs.secondary,
background: cs.secondaryContainer,
icon: Icons.hourglass_top,
label: 'Synchronizing',
);
static ConnectionTokens connected(ColorScheme cs) => ConnectionTokens(
color: cs.tertiary,
background: cs.tertiaryContainer,
icon: Icons.cloud_done,
label: 'Connected',
);
static ConnectionTokens reconnecting(ColorScheme cs) => ConnectionTokens(
color: cs.error,
background: cs.errorContainer,
icon: Icons.restart_alt,
label: 'Reconnecting',
);
static ConnectionTokens lost(ColorScheme cs) => ConnectionTokens(
color: cs.error,
background: cs.errorContainer,
icon: Icons.warning_amber,
label: 'Connection Lost',
);
}
/// Tokenised visual mapping for a voice transmit/mute state.
class VoiceTokens {
final Color color;
final Color? background;
final IconData icon;
const VoiceTokens({
required this.color,
required this.icon,
this.background,
});
static VoiceTokens idle(ColorScheme cs) => VoiceTokens(
color: cs.onSurfaceVariant,
icon: Icons.mic_none,
);
static VoiceTokens pttHeld(ColorScheme cs) => VoiceTokens(
color: cs.primary,
background: cs.primaryContainer,
icon: Icons.mic,
);
static VoiceTokens voiceActivity(ColorScheme cs) => VoiceTokens(
color: cs.tertiary,
background: cs.tertiaryContainer,
icon: Icons.mic,
);
static VoiceTokens continuousTx(ColorScheme cs) => VoiceTokens(
color: cs.primary,
background: cs.primaryContainer,
icon: Icons.settings_voice,
);
static VoiceTokens muted(ColorScheme cs) => VoiceTokens(
color: cs.error,
background: cs.errorContainer,
icon: Icons.mic_off,
);
static VoiceTokens outputMuted(ColorScheme cs) => VoiceTokens(
color: cs.error,
icon: Icons.headset_off,
);
static VoiceTokens deafened(ColorScheme cs) => VoiceTokens(
color: cs.error,
background: cs.errorContainer,
icon: Icons.hearing_disabled,
);
}
/// Tokenised visual mapping for network latency/quality.
class LatencyTokens {
final Color color;
final String label;
const LatencyTokens({required this.color, required this.label});
static LatencyTokens good(ColorScheme cs) => LatencyTokens(
color: cs.tertiary,
label: 'Good',
);
static LatencyTokens warning(ColorScheme cs) => LatencyTokens(
color: cs.error,
label: 'Warning',
);
static LatencyTokens unknown(ColorScheme cs) => LatencyTokens(
color: cs.outlineVariant,
label: '',
);
}
/// Tokens for channel membership status.
class ChannelTokens {
final IconData? icon;
final String? label;
const ChannelTokens({this.icon, this.label});
static ChannelTokens inChannel(ColorScheme cs) => ChannelTokens(
icon: Icons.headset_mic,
label: 'Connected',
);
static ChannelTokens joining(ColorScheme cs) => ChannelTokens(
icon: Icons.login,
label: 'Joining...',
);
static ChannelTokens notInChannel(ColorScheme cs) => ChannelTokens(
icon: null,
label: null,
);
}
/// Tokens for PTT capability display.
class PttTokens {
final String label;
final Color? color;
const PttTokens({required this.label, this.color});
static PttTokens focused(ColorScheme cs) => PttTokens(
label: 'Keyboard PTT',
color: cs.tertiary,
);
static PttTokens platformHook(ColorScheme cs) => PttTokens(
label: 'Global PTT',
color: cs.primary,
);
static PttTokens none(ColorScheme cs) => PttTokens(
label: 'No PTT input',
color: cs.error,
);
}
@@ -0,0 +1,196 @@
// SPDX-License-Identifier: Apache-2.0
// SRS-115: Unsupported/degraded platform behaviours documentation.
// Describes what is available, degraded, or unavailable per platform.
import 'dart:io' show Platform;
import 'package:flutter/foundation.dart' show kIsWeb;
/// Describes capability tier for a feature on a platform.
enum CapabilityTier { supported, degraded, unavailable }
/// A single platform capability description.
class PlatformCapability {
final String feature;
final String description;
final CapabilityTier tier;
const PlatformCapability({
required this.feature,
required this.description,
required this.tier,
});
}
/// Returns the current platform's capability matrix.
List<PlatformCapability> currentPlatformCapabilities() {
if (kIsWeb) {
return [
PlatformCapability(
feature: 'Audio',
description: 'Web audio is not supported in the beta.',
tier: CapabilityTier.unavailable,
),
];
}
if (Platform.isAndroid) {
return _androidCapabilities();
}
if (Platform.isIOS) {
return _iosCapabilities();
}
if (Platform.isMacOS) {
return _macosCapabilities();
}
if (Platform.isWindows) {
return _windowsCapabilities();
}
if (Platform.isLinux) {
return _linuxCapabilities();
}
return [];
}
List<PlatformCapability> _androidCapabilities() => [
const PlatformCapability(
feature: 'Voice capture',
description: 'Oboe AAudio backend; Bluetooth SCO supported.',
tier: CapabilityTier.supported,
),
const PlatformCapability(
feature: 'Voice processing',
description: 'WebRTC AEC3/NS/AGC2, VAD via Silero ONNX.',
tier: CapabilityTier.supported,
),
const PlatformCapability(
feature: 'Foreground service',
description: 'Persistent notification during active connection.',
tier: CapabilityTier.supported,
),
const PlatformCapability(
feature: 'Global PTT hotkey',
description: 'Not available on Android.',
tier: CapabilityTier.unavailable,
),
const PlatformCapability(
feature: 'Audio device selection',
description: 'System-managed; wired/BT/SCO automatic routing.',
tier: CapabilityTier.degraded,
),
const PlatformCapability(
feature: 'Push-to-talk button',
description: 'Media button / volume-key binding supported.',
tier: CapabilityTier.supported,
),
];
List<PlatformCapability> _iosCapabilities() => [
const PlatformCapability(
feature: 'Voice capture',
description: 'VoiceProcessingIO AudioUnit (default); Sonora raw mode optional.',
tier: CapabilityTier.supported,
),
const PlatformCapability(
feature: 'Voice processing',
description: 'Platform AEC/NS/AGC via VPIO; optional Rust AEC3/NS/AGC2 via Sonora.',
tier: CapabilityTier.supported,
),
const PlatformCapability(
feature: 'Audio route switching',
description: 'Handles speaker/earpiece/Bluetooth route changes and interruptions.',
tier: CapabilityTier.supported,
),
const PlatformCapability(
feature: 'Global PTT hotkey',
description: 'Not available on iOS.',
tier: CapabilityTier.unavailable,
),
const PlatformCapability(
feature: 'Background audio',
description: 'Supported via AVAudioSession background mode.',
tier: CapabilityTier.supported,
),
];
List<PlatformCapability> _macosCapabilities() => [
const PlatformCapability(
feature: 'Voice capture',
description: 'cpal device enumeration; VoiceProcessingIO optional.',
tier: CapabilityTier.supported,
),
const PlatformCapability(
feature: 'Voice processing',
description: 'WebRTC AEC3/NS/AGC2; VPIO available on macOS.',
tier: CapabilityTier.supported,
),
const PlatformCapability(
feature: 'Global PTT hotkey',
description: 'Supported via platform-global key-binding API.',
tier: CapabilityTier.supported,
),
const PlatformCapability(
feature: 'Secure storage',
description: 'macOS Keychain.',
tier: CapabilityTier.supported,
),
const PlatformCapability(
feature: 'Audio device selection',
description: 'System audio output route picker.',
tier: CapabilityTier.supported,
),
];
List<PlatformCapability> _windowsCapabilities() => [
const PlatformCapability(
feature: 'Voice capture',
description: 'WASAPI via cpal.',
tier: CapabilityTier.supported,
),
const PlatformCapability(
feature: 'Voice processing',
description: 'WebRTC AEC3/NS/AGC2.',
tier: CapabilityTier.supported,
),
const PlatformCapability(
feature: 'Global PTT hotkey',
description: 'Supported via platform-global hotkey binding.',
tier: CapabilityTier.supported,
),
const PlatformCapability(
feature: 'Secure storage',
description: 'Windows Credential Manager / DPAPI.',
tier: CapabilityTier.supported,
),
const PlatformCapability(
feature: 'Installer',
description: 'MSIX packaging not yet available in beta.',
tier: CapabilityTier.unavailable,
),
];
List<PlatformCapability> _linuxCapabilities() => [
const PlatformCapability(
feature: 'Voice capture',
description: 'PulseAudio/ALSA via cpal.',
tier: CapabilityTier.supported,
),
const PlatformCapability(
feature: 'Voice processing',
description: 'WebRTC AEC3/NS/AGC2; no platform VPIO.',
tier: CapabilityTier.supported,
),
const PlatformCapability(
feature: 'Global PTT hotkey',
description: 'Supported via X11/Wayland global key-binding. ',
tier: CapabilityTier.supported,
),
const PlatformCapability(
feature: 'Secure storage',
description: 'Secret Service / libsecret.',
tier: CapabilityTier.supported,
),
const PlatformCapability(
feature: 'Desktop environment',
description: 'DE-specific behaviour: screen locker may suspend audio.',
tier: CapabilityTier.degraded,
),
];
+8 -13
View File
@@ -181,24 +181,19 @@
"audioRouteChangeFailed": "Could not change audio output.",
"iosAudioInterrupted": "Audio interrupted by system (phone call)",
"iosAudioResuming": "Audio resuming",
"settingsAction": "Settings",
"appSettingsTitle": "Settings",
"pokeAlertsTitle": "Poke alerts",
"pokeAlertsDescription": "Show a pop-up when someone pokes you. Pokes are always saved in Channel Chat.",
"pokeDialogTitle": "You were poked",
"pokeDialogCloseAction": "Close",
"pokeDialogIncomingNoMessage": "{time} {sender} pokes you",
"@pokeDialogIncomingNoMessage": {
"pokeSnackBarClearAction": "Clear",
"pokeSnackBarMoreIndicator": "...",
"pokeSnackBarIncomingNoMessage": "{sender} pokes you",
"@pokeSnackBarIncomingNoMessage": {
"placeholders": {
"time": { "type": "String" },
"sender": { "type": "String" }
}
},
"pokeDialogIncomingWithMessage": "{time} {sender} pokes you with a message",
"@pokeDialogIncomingWithMessage": {
"pokeSnackBarIncomingWithMessage": "{sender} pokes you: {message}",
"@pokeSnackBarIncomingWithMessage": {
"placeholders": {
"time": { "type": "String" },
"sender": { "type": "String" }
"sender": { "type": "String" },
"message": { "type": "String" }
}
},
"pokeHistorySelfNoMessage": "<{time}> You poked \"{target}\".",
+8 -13
View File
@@ -135,24 +135,19 @@
"audioRouteChangeFailed": "无法切换音频输出。",
"iosAudioInterrupted": "系统已中断音频(电话通话)",
"iosAudioResuming": "音频正在恢复",
"settingsAction": "设置",
"appSettingsTitle": "设置",
"pokeAlertsTitle": "戳一戳提醒",
"pokeAlertsDescription": "收到戳一戳时显示弹窗。戳一戳记录始终会保存在频道聊天中。",
"pokeDialogTitle": "你被戳了一下",
"pokeDialogCloseAction": "关闭",
"pokeDialogIncomingNoMessage": "{time} {sender} 戳了你一下",
"@pokeDialogIncomingNoMessage": {
"pokeSnackBarClearAction": "清除",
"pokeSnackBarMoreIndicator": "...",
"pokeSnackBarIncomingNoMessage": "{sender} 戳了你一下",
"@pokeSnackBarIncomingNoMessage": {
"placeholders": {
"time": { "type": "String" },
"sender": { "type": "String" }
}
},
"pokeDialogIncomingWithMessage": "{time} {sender} 戳了你一下并附带消息",
"@pokeDialogIncomingWithMessage": {
"pokeSnackBarIncomingWithMessage": "{sender} 戳了你一下{message}",
"@pokeSnackBarIncomingWithMessage": {
"placeholders": {
"time": { "type": "String" },
"sender": { "type": "String" }
"sender": { "type": "String" },
"message": { "type": "String" }
}
},
"pokeHistorySelfNoMessage": "<{time}> 你戳了“{target}”一下。",
@@ -823,53 +823,29 @@ abstract class AppL10n {
/// **'Audio resuming'**
String get iosAudioResuming;
/// No description provided for @settingsAction.
/// No description provided for @pokeSnackBarClearAction.
///
/// In en, this message translates to:
/// **'Settings'**
String get settingsAction;
/// **'Clear'**
String get pokeSnackBarClearAction;
/// No description provided for @appSettingsTitle.
/// No description provided for @pokeSnackBarMoreIndicator.
///
/// In en, this message translates to:
/// **'Settings'**
String get appSettingsTitle;
/// **'...'**
String get pokeSnackBarMoreIndicator;
/// No description provided for @pokeAlertsTitle.
/// No description provided for @pokeSnackBarIncomingNoMessage.
///
/// In en, this message translates to:
/// **'Poke alerts'**
String get pokeAlertsTitle;
/// **'{sender} pokes you'**
String pokeSnackBarIncomingNoMessage(String sender);
/// No description provided for @pokeAlertsDescription.
/// No description provided for @pokeSnackBarIncomingWithMessage.
///
/// In en, this message translates to:
/// **'Show a pop-up when someone pokes you. Pokes are always saved in Channel Chat.'**
String get pokeAlertsDescription;
/// No description provided for @pokeDialogTitle.
///
/// In en, this message translates to:
/// **'You were poked'**
String get pokeDialogTitle;
/// No description provided for @pokeDialogCloseAction.
///
/// In en, this message translates to:
/// **'Close'**
String get pokeDialogCloseAction;
/// No description provided for @pokeDialogIncomingNoMessage.
///
/// In en, this message translates to:
/// **'{time} {sender} pokes you'**
String pokeDialogIncomingNoMessage(String time, String sender);
/// No description provided for @pokeDialogIncomingWithMessage.
///
/// In en, this message translates to:
/// **'{time} {sender} pokes you with a message'**
String pokeDialogIncomingWithMessage(String time, String sender);
/// **'{sender} pokes you: {message}'**
String pokeSnackBarIncomingWithMessage(String sender, String message);
/// No description provided for @pokeHistorySelfNoMessage.
///
@@ -416,32 +416,19 @@ class AppL10nEn extends AppL10n {
String get iosAudioResuming => 'Audio resuming';
@override
String get settingsAction => 'Settings';
String get pokeSnackBarClearAction => 'Clear';
@override
String get appSettingsTitle => 'Settings';
String get pokeSnackBarMoreIndicator => '...';
@override
String get pokeAlertsTitle => 'Poke alerts';
@override
String get pokeAlertsDescription =>
'Show a pop-up when someone pokes you. Pokes are always saved in Channel Chat.';
@override
String get pokeDialogTitle => 'You were poked';
@override
String get pokeDialogCloseAction => 'Close';
@override
String pokeDialogIncomingNoMessage(String time, String sender) {
return '$time $sender pokes you';
String pokeSnackBarIncomingNoMessage(String sender) {
return '$sender pokes you';
}
@override
String pokeDialogIncomingWithMessage(String time, String sender) {
return '$time $sender pokes you with a message';
String pokeSnackBarIncomingWithMessage(String sender, String message) {
return '$sender pokes you: $message';
}
@override
@@ -406,31 +406,19 @@ class AppL10nZh extends AppL10n {
String get iosAudioResuming => '音频正在恢复';
@override
String get settingsAction => '设置';
String get pokeSnackBarClearAction => '清除';
@override
String get appSettingsTitle => '设置';
String get pokeSnackBarMoreIndicator => '...';
@override
String get pokeAlertsTitle => '戳一戳提醒';
@override
String get pokeAlertsDescription => '收到戳一戳时显示弹窗。戳一戳记录始终会保存在频道聊天中。';
@override
String get pokeDialogTitle => '你被戳了一下';
@override
String get pokeDialogCloseAction => '关闭';
@override
String pokeDialogIncomingNoMessage(String time, String sender) {
return '$time $sender 戳了你一下';
String pokeSnackBarIncomingNoMessage(String sender) {
return '$sender 戳了你一下';
}
@override
String pokeDialogIncomingWithMessage(String time, String sender) {
return '$time $sender 戳了你一下并附带消息';
String pokeSnackBarIncomingWithMessage(String sender, String message) {
return '$sender 戳了你一下$message';
}
@override
+170 -199
View File
@@ -145,7 +145,7 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
ConnectionPhase _phase = ConnectionPhase.idle;
bool get _serverReachable => _phase.isServerReachable;
rust.BridgeSnapshot? _snapshot;
String? _error;
final List<String> _uiDiagnostics = [];
rust.BridgeAudioStats? _audioStats;
Timer? _statsTimer;
int _statsTick = 0;
@@ -195,11 +195,10 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
final List<ChatEntry> _chatMessages = [];
int _chatUnread = 0;
bool _chatOpen = false;
bool _showPokeDialogs = true;
final ValueNotifier<List<_ReceivedPoke>> _pokeDialogPokes = ValueNotifier(
final ValueNotifier<List<_ReceivedPoke>> _pokeSnackBarPokes = ValueNotifier(
const [],
);
bool _pokeDialogShowing = false;
bool _pokeSnackBarVisible = false;
IconData? _audioRoute;
// SDD-106 / SRS-209: Android RECORD_AUDIO runtime permission service.
@@ -246,25 +245,12 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
if (settings.nickname.isNotEmpty) {
_nickCtl.text = settings.nickname;
}
if (mounted) {
setState(() => _showPokeDialogs = settings.showPokeDialogs);
} else {
_showPokeDialogs = settings.showPokeDialogs;
}
} catch (_) {}
}
Future<void> _saveUiSettings({
String? host,
String? nickname,
bool? showPokeDialogs,
}) async {
Future<void> _saveUiSettings({String? host, String? nickname}) async {
try {
await _uiPreferences.saveSettings(
host: host,
nickname: nickname,
showPokeDialogs: showPokeDialogs,
);
await _uiPreferences.saveSettings(host: host, nickname: nickname);
} catch (_) {}
}
@@ -342,12 +328,45 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
});
} catch (e) {
if (!mounted) return;
setState(() => _error = e.toString());
_showUiError('clear permission mute', e);
} finally {
_permissionHardMuteClearInFlight = false;
}
}
void _recordUiDiagnostic(String area, Object error) {
final line = '${DateTime.now().toIso8601String()} [$area] $error';
debugPrint('chanora: $line');
_uiDiagnostics.add(line);
if (_uiDiagnostics.length > 100) {
_uiDiagnostics.removeRange(0, _uiDiagnostics.length - 100);
}
}
void _showUiError(String area, Object error) {
if (!mounted) return;
_showUiErrorSnackBar(area: area, error: error);
}
void _showUiErrorSnackBar({
required String area,
required Object error,
String? displayMessage,
}) {
_recordUiDiagnostic(area, error);
final l10n = AppL10n.of(context);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
behavior: SnackBarBehavior.floating,
content: Text(
displayMessage ?? l10n.statusError(error.toString()),
maxLines: 3,
overflow: TextOverflow.ellipsis,
),
),
);
}
bool _handleFocusedPttKey(KeyEvent event) {
final label = pttDisplayLabelForKey(event.logicalKey);
final isBoundKey =
@@ -564,13 +583,11 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
}
});
if (isPoke) {
if (_showPokeDialogs) {
_appendPokeDialog(
senderName: senderName,
message: message,
receivedAt: receivedAt,
);
}
_showPokeSnackBar(
senderName: senderName,
message: message,
receivedAt: receivedAt,
);
return;
}
if (!_chatOpen && _chatUnread > 0) {
@@ -656,7 +673,7 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
_hostCtl.dispose();
_nickCtl.dispose();
_passwordCtl.dispose();
_pokeDialogPokes.dispose();
_pokeSnackBarPokes.dispose();
_androidPermissions.recordAudioState.removeListener(
_onRecordAudioPermissionChanged,
);
@@ -678,7 +695,6 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
}) async {
setState(() {
_phase = ConnectionPhase.connecting;
_error = null;
_snapshot = null;
_chatMessages.clear();
});
@@ -718,8 +734,8 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
}
setState(() {
_phase = ConnectionPhase.idle;
_error = errorStr;
});
_showUiErrorSnackBar(area: 'connect', error: e);
}
}
@@ -786,7 +802,7 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
await rust.setPtt(active: active);
} catch (e) {
if (!mounted || !reportError) return;
setState(() => _error = e.toString());
_showUiError('ptt', e);
}
}
@@ -800,7 +816,7 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
});
} catch (e) {
if (!mounted) return;
setState(() => _error = e.toString());
_showUiError('output mute', e);
}
}
@@ -812,7 +828,6 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
if (_pendingVoiceChannelId != null) return;
if (ch.id == _currentVoiceChannelId) return;
final l10n = AppL10n.of(context);
final messenger = ScaffoldMessenger.of(context);
String? password;
if (askForPassword) {
password = await _askChannelPassword(l10n);
@@ -872,18 +887,15 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
} catch (e) {
if (!mounted) return;
// Surface as a SnackBar so the user sees it even while
// connected (the persistent _error string lives in the
// pre-connect area and is hidden post-connect). The message
// is selected by TS3 error code per the canonical
// catalogue at https://github.com/ReSpeak/tsdeclarations.
// connected. The message is selected by TS3 error code per
// the canonical catalogue at
// https://github.com/ReSpeak/tsdeclarations.
final message = channelJoinErrorMessage(l10n, e);
setState(() => _pendingVoiceChannelId = null);
messenger.showSnackBar(
SnackBar(
content: Text(message),
behavior: SnackBarBehavior.floating,
duration: const Duration(seconds: 4),
),
_showUiErrorSnackBar(
area: 'join channel',
error: e,
displayMessage: message,
);
}
}
@@ -914,7 +926,7 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
});
} catch (e) {
if (!mounted) return;
setState(() => _error = e.toString());
_showUiError('hard mute', e);
}
}
@@ -938,7 +950,7 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
} catch (e) {
if (held) {
if (!mounted) return;
setState(() => _error = e.toString());
_showUiError('onscreen ptt', e);
}
}
}
@@ -977,7 +989,7 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
setState(() => _transmitMode = mode);
} catch (e) {
if (!mounted) return;
setState(() => _error = e.toString());
_showUiError('transmit mode', e);
}
},
onReleaseTailChanged: (ms) async {
@@ -987,7 +999,7 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
setState(() => _releaseTailMs = ms);
} catch (e) {
if (!mounted) return;
setState(() => _error = e.toString());
_showUiError('release tail', e);
}
},
onAudioConfigChanged: (config) async {
@@ -995,7 +1007,7 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
await rust.setAudioProcessingConfig(config: config);
} catch (e) {
if (!mounted) return;
setState(() => _error = e.toString());
_showUiError('audio processing config', e);
}
},
);
@@ -1023,7 +1035,7 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
await rust.setAudioProcessingConfig(config: result.audioConfig);
} catch (e) {
if (!mounted) return;
setState(() => _error = e.toString());
_showUiError('voice settings', e);
}
if (result.bindKeyRequested && mounted) {
await _onConfigurePtt(context);
@@ -1055,7 +1067,7 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
setState(() => _applySnapshot(snap));
} catch (e) {
if (!mounted) return;
setState(() => _error = e.toString());
_showUiError('refresh snapshot', e);
}
}
@@ -1113,7 +1125,6 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
_phase = phase;
_snapshot = null;
_audioStats = null;
_error = null;
_inputMuted = false;
_outputMuted = false;
_inChannel = false;
@@ -1171,36 +1182,52 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
if (mounted) setState(() => _chatOpen = false);
}
void _appendPokeDialog({
void _showPokeSnackBar({
required String senderName,
required String message,
required DateTime receivedAt,
}) {
_pokeDialogPokes.value = [
..._pokeDialogPokes.value,
_pokeSnackBarPokes.value = [
..._pokeSnackBarPokes.value,
_ReceivedPoke(
senderName: senderName,
message: message,
receivedAt: receivedAt,
),
];
unawaited(_showPokeDialog());
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) _renderPokeSnackBar();
});
}
Future<void> _showPokeDialog() async {
if (_pokeDialogShowing || _pokeDialogPokes.value.isEmpty || !mounted) {
return;
}
_pokeDialogShowing = true;
try {
await showDialog<void>(
context: context,
builder: (ctx) => _PokeDialog(pokes: _pokeDialogPokes),
);
} finally {
_pokeDialogPokes.value = const [];
_pokeDialogShowing = false;
}
void _renderPokeSnackBar() {
if (_pokeSnackBarPokes.value.isEmpty || _pokeSnackBarVisible) return;
_pokeSnackBarVisible = true;
final messenger = ScaffoldMessenger.of(context);
final controller = messenger.showSnackBar(
SnackBar(
behavior: SnackBarBehavior.floating,
margin: _chatSnackBarMargin(),
duration: const Duration(days: 365),
dismissDirection: DismissDirection.none,
content: _PokeSnackBarContent(pokes: _pokeSnackBarPokes),
action: SnackBarAction(
label: AppL10n.of(context).pokeSnackBarClearAction,
onPressed: () {
_pokeSnackBarPokes.value = const [];
_pokeSnackBarVisible = false;
},
),
),
);
controller.closed.then((_) {
if (!mounted) return;
_pokeSnackBarVisible = false;
if (_pokeSnackBarPokes.value.isEmpty) return;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) _renderPokeSnackBar();
});
});
}
void _showChatMessageSnackBar({
@@ -1208,6 +1235,7 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
required String message,
required rust.BridgeMessageTarget target,
}) {
if (_pokeSnackBarPokes.value.isNotEmpty) return;
final messenger = ScaffoldMessenger.of(context);
messenger.hideCurrentSnackBar();
messenger.showSnackBar(
@@ -1313,7 +1341,11 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
Future<void> _onShowDiagnostics(BuildContext context) async {
final l10n = AppL10n.of(context);
final text = rust.exportDiagnostics();
final rustText = rust.exportDiagnostics();
final uiText = _uiDiagnostics.isEmpty
? 'UI diagnostics: none'
: ['UI diagnostics:', ..._uiDiagnostics].join('\n');
final text = '$uiText\n\nRust diagnostics:\n$rustText';
if (!mounted) return;
await showDialog<void>(
context: context,
@@ -1370,10 +1402,7 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
);
} catch (e) {
if (!mounted) return;
final messenger = ScaffoldMessenger.of(this.context);
messenger.showSnackBar(
SnackBar(content: Text(l10n.statusError(e.toString()))),
);
_showUiErrorSnackBar(area: 'ptt portal binding', error: e);
}
return;
}
@@ -1405,13 +1434,7 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
if (!mounted) return;
// Surface the failure as a snackbar so the user sees that
// their binding did not stick.
if (!mounted) return;
// Use the State's context (guaranteed valid because we
// re-checked `mounted` immediately above).
final messenger = ScaffoldMessenger.of(this.context);
messenger.showSnackBar(
SnackBar(content: Text(l10n.statusError(e.toString()))),
);
_showUiErrorSnackBar(area: 'ptt binding', error: e);
}
}
@@ -1514,7 +1537,7 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
await _reloadBookmarks();
} catch (e) {
if (!mounted) return;
setState(() => _error = e.toString());
_showUiError('add bookmark', e);
}
}
@@ -1524,7 +1547,7 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
await _reloadBookmarks();
} catch (e) {
if (!mounted) return;
setState(() => _error = e.toString());
_showUiError('delete bookmark', e);
}
}
@@ -1573,24 +1596,10 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
).showSnackBar(SnackBar(content: Text('Added bookmark "$bookmarkName"')));
} catch (e) {
if (!mounted) return;
setState(() => _error = e.toString());
_showUiError('teamspeak link bookmark', e);
}
}
Future<void> _onOpenAppSettings() async {
await Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => _AppSettingsPage(
showPokeDialogs: _showPokeDialogs,
onShowPokeDialogsChanged: (value) {
setState(() => _showPokeDialogs = value);
unawaited(_saveUiSettings(showPokeDialogs: value));
},
),
),
);
}
@override
Widget build(BuildContext context) {
final l10n = AppL10n.of(context);
@@ -1617,11 +1626,6 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
onPressed: _toggleOutputMute,
),
],
IconButton(
tooltip: l10n.settingsAction,
icon: const Icon(Icons.settings_outlined),
onPressed: _onOpenAppSettings,
),
IconButton(
tooltip: l10n.aboutAction,
icon: const Icon(Icons.info_outline),
@@ -1656,7 +1660,6 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
final statusText = connectionStatusText(
phase: _phase,
l10n: l10n,
error: _error,
serverName: _snapshot?.serverName,
lostReason: _lostReason,
reconnectAttempt: _reconnectAttempt,
@@ -1686,11 +1689,16 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
if (!isWideSnapshot) ...[banner, const SizedBox(height: 12)],
if (!_serverReachable)
Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(connTokens.icon, size: 18, color: connTokens.color),
const SizedBox(width: 6),
Text(statusText, style: theme.textTheme.titleMedium),
Expanded(
child: Text(
statusText,
softWrap: true,
style: theme.textTheme.titleMedium,
),
),
],
),
if (_serverReachable && _audioRoute != null) ...[
@@ -1928,75 +1936,74 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
}
}
class _PokeDialog extends StatelessWidget {
const _PokeDialog({required this.pokes});
class _PokeSnackBarContent extends StatelessWidget {
const _PokeSnackBarContent({required this.pokes});
final ValueListenable<List<_ReceivedPoke>> pokes;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final l10n = AppL10n.of(context);
return AlertDialog(
icon: Icon(
Icons.notifications_active_outlined,
color: theme.colorScheme.primary,
),
title: Text(l10n.pokeDialogTitle),
content: ValueListenableBuilder<List<_ReceivedPoke>>(
valueListenable: pokes,
builder: (context, entries, _) => ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 360, maxHeight: 360),
child: ListView.separated(
shrinkWrap: true,
itemCount: entries.length,
separatorBuilder: (_, _) => const SizedBox(height: 10),
itemBuilder: (_, index) => _PokeDialogEntry(poke: entries[index]),
),
),
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: Text(l10n.pokeDialogCloseAction),
),
],
return ValueListenableBuilder<List<_ReceivedPoke>>(
valueListenable: pokes,
builder: (context, entries, _) {
final l10n = AppL10n.of(context);
final visible = entries.length <= 3
? entries
: entries.sublist(entries.length - 3);
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (entries.length > 3)
Padding(
padding: const EdgeInsetsDirectional.only(bottom: 2),
child: Text(
l10n.pokeSnackBarMoreIndicator,
style: const TextStyle(fontWeight: FontWeight.w600),
),
),
for (final poke in visible) _PokeSnackBarRow(poke: poke),
],
);
},
);
}
}
class _AppSettingsPage extends StatefulWidget {
const _AppSettingsPage({
required this.showPokeDialogs,
required this.onShowPokeDialogsChanged,
});
class _PokeSnackBarRow extends StatelessWidget {
const _PokeSnackBarRow({required this.poke});
final bool showPokeDialogs;
final ValueChanged<bool> onShowPokeDialogsChanged;
@override
State<_AppSettingsPage> createState() => _AppSettingsPageState();
}
class _AppSettingsPageState extends State<_AppSettingsPage> {
late bool _showPokeDialogs = widget.showPokeDialogs;
final _ReceivedPoke poke;
@override
Widget build(BuildContext context) {
final l10n = AppL10n.of(context);
return Scaffold(
appBar: AppBar(title: Text(l10n.appSettingsTitle)),
body: ListView(
final message = poke.message.trim();
final text = message.isEmpty
? l10n.pokeSnackBarIncomingNoMessage(poke.senderName)
: l10n.pokeSnackBarIncomingWithMessage(poke.senderName, message);
return Padding(
padding: const EdgeInsets.symmetric(vertical: 1),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SwitchListTile(
title: Text(l10n.pokeAlertsTitle),
subtitle: Text(l10n.pokeAlertsDescription),
value: _showPokeDialogs,
onChanged: (value) {
setState(() => _showPokeDialogs = value);
widget.onShowPokeDialogsChanged(value);
},
Expanded(
child: Text(
text,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontWeight: FontWeight.w600),
),
),
const SizedBox(width: 12),
Text(
pokeSnackBarTimeLabel(poke.receivedAt),
style: TextStyle(
color: Theme.of(
context,
).colorScheme.onInverseSurface.withValues(alpha: 0.72),
),
),
],
),
@@ -2004,43 +2011,7 @@ class _AppSettingsPageState extends State<_AppSettingsPage> {
}
}
class _PokeDialogEntry extends StatelessWidget {
const _PokeDialogEntry({required this.poke});
final _ReceivedPoke poke;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final l10n = AppL10n.of(context);
final message = poke.message.trim();
final hasMessage = message.isNotEmpty;
final time = chatTimeLabel(poke.receivedAt);
final detail = hasMessage
? l10n.pokeDialogIncomingWithMessage(time, poke.senderName)
: l10n.pokeDialogIncomingNoMessage(time, poke.senderName);
return DecoratedBox(
decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(8),
),
child: Padding(
padding: const EdgeInsets.all(12),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
detail,
style: theme.textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w600,
),
),
if (hasMessage) ...[const SizedBox(height: 8), Text(message)],
],
),
),
);
}
String pokeSnackBarTimeLabel(DateTime timestamp) {
String two(int value) => value.toString().padLeft(2, '0');
return '${two(timestamp.hour)}:${two(timestamp.minute)}';
}
@@ -0,0 +1,40 @@
class AndroidAudioOutputDevice {
const AndroidAudioOutputDevice({
required this.id,
required this.name,
required this.type,
required this.isSelected,
required this.isAvailableForCommunication,
});
final String id;
final String name;
final String type;
final bool isSelected;
final bool isAvailableForCommunication;
factory AndroidAudioOutputDevice.fromMap(Map<dynamic, dynamic> map) {
return AndroidAudioOutputDevice(
id: map['id']?.toString() ?? '',
name: map['name']?.toString() ?? '',
type: map['type']?.toString() ?? 'unknown',
isSelected: map['isSelected'] == true,
isAvailableForCommunication: map['isAvailableForCommunication'] == true,
);
}
}
List<AndroidAudioOutputDevice> parseAndroidAudioOutputDevices(
List<dynamic> raw,
) {
return raw
.whereType<Map<dynamic, dynamic>>()
.map(AndroidAudioOutputDevice.fromMap)
.toList();
}
AndroidAudioOutputDevice? selectedAndroidAudioOutputDevice(
List<AndroidAudioOutputDevice> devices,
) {
return devices.where((device) => device.isSelected).firstOrNull;
}
@@ -0,0 +1,97 @@
import 'dart:io' show File;
import 'package:connectivity_plus/connectivity_plus.dart';
import 'package:flutter/services.dart';
import 'package:package_info_plus/package_info_plus.dart';
import 'package:path_provider/path_provider.dart';
import '../src/rust/api.dart' as rust;
const String appSemverBaseline = 'v0.1.0';
const String _sileroVadAsset = 'assets/models/silero_vad.onnx';
const String _tenVadAsset = 'assets/models/ten_vad.onnx';
Future<File> _copyBundledAssetToDocuments({
required String assetPath,
required String fileName,
}) async {
final dir = await getApplicationDocumentsDirectory();
final file = File('${dir.path}/$fileName');
final data = await rootBundle.load(assetPath);
final bytes = data.buffer.asUint8List(data.offsetInBytes, data.lengthInBytes);
if (await file.exists() && await file.length() == bytes.length) {
return file;
}
await file.writeAsBytes(bytes, flush: true);
return file;
}
Future<void> configureBundledVadModels() async {
final silero = await _copyBundledAssetToDocuments(
assetPath: _sileroVadAsset,
fileName: 'silero_vad.onnx',
);
final ten = await _copyBundledAssetToDocuments(
assetPath: _tenVadAsset,
fileName: 'ten_vad.onnx',
);
await rust.setVadModelPath(path: silero.path);
await rust.setTenVadModelPath(path: ten.path);
}
/// Resolve the human-readable app version displayed in About.
///
/// The semver baseline is kept in code because iOS strips pre-release
/// identifiers from `CFBundleShortVersionString`; `package_info_plus`
/// still provides the platform build counter.
Future<String> resolveAppVersion({
String semverBaseline = appSemverBaseline,
}) async {
try {
final info = await PackageInfo.fromPlatform();
return appVersionFromBuildNumber(
semverBaseline: semverBaseline,
buildNumber: info.buildNumber,
);
} catch (_) {
return semverBaseline;
}
}
String appVersionFromBuildNumber({
required String semverBaseline,
required String buildNumber,
}) {
final build = buildNumber.isEmpty ? '' : '+$buildNumber';
return '$semverBaseline$build';
}
Future<void> wireStorage() async {
try {
final dir = await getApplicationSupportDirectory();
await rust.initStorage(dir: dir.path);
} catch (_) {
// Best-effort; missing storage just means no identity persistence
// and no bookmark list this session.
}
}
rust.BridgeNetworkState _mapConnectivity(List<ConnectivityResult> results) {
if (results.isEmpty) return rust.BridgeNetworkState.unknown;
final allNone = results.every((r) => r == ConnectivityResult.none);
if (allNone) return rust.BridgeNetworkState.offline;
return rust.BridgeNetworkState.online;
}
Future<void> wireConnectivity() async {
final connectivity = Connectivity();
try {
final initial = await connectivity.checkConnectivity();
rust.setNetworkState(state: _mapConnectivity(initial));
} catch (_) {}
connectivity.onConnectivityChanged.listen((results) {
rust.setNetworkState(state: _mapConnectivity(results));
});
}
@@ -0,0 +1,107 @@
import 'dart:io' show Platform;
import 'package:flutter/services.dart';
import '../src/rust/api.dart' as rust;
const iosAudioLifecycleChannelName = 'chanora/ios_audio_lifecycle';
const androidAudioLifecycleChannelName = 'chanora/android_audio_lifecycle';
rust.BridgeAudioRoute parseBridgeAudioRoute(String value) {
switch (value) {
case 'Earpiece':
return rust.BridgeAudioRoute.earpiece;
case 'Speaker':
return rust.BridgeAudioRoute.speaker;
case 'WiredHeadset':
return rust.BridgeAudioRoute.wiredHeadset;
case 'BluetoothHfp':
return rust.BridgeAudioRoute.bluetoothHfp;
case 'BluetoothA2dp':
return rust.BridgeAudioRoute.bluetoothA2Dp;
default:
return rust.BridgeAudioRoute.unknown;
}
}
void wireAudioLifecycle() {
wireIosAudioLifecycle();
wireAndroidAudioLifecycle();
}
/// Wire the iOS AVAudioSession lifecycle MethodChannel.
///
/// Swift side (AppDelegate) posts route-change and interruption events through
/// this channel. The handler dispatches them to the FRB bridge functions on
/// the Rust side.
void wireIosAudioLifecycle({
MethodChannel channel = const MethodChannel(iosAudioLifecycleChannelName),
}) {
channel.setMethodCallHandler((call) async {
try {
switch (call.method) {
case 'handleRouteChange':
final routeStr = call.arguments as String? ?? 'Unknown';
final route = parseBridgeAudioRoute(routeStr);
rust.handleRouteChange(route: route);
break;
case 'handleMediaServicesReset':
final routeClass = call.arguments as String? ?? 'Unknown';
rust.handleMediaServicesResetWithRoute(routeClass: routeClass);
break;
case 'handleInterruptionBegan':
rust.handleInterruptionBegan();
break;
case 'handleInterruptionEnded':
final shouldResume = call.arguments as bool? ?? false;
rust.handleInterruptionEnded(shouldResume: shouldResume);
break;
case 'handleWillResignActive':
case 'handleDidEnterBackground':
rust.handleInterruptionBegan();
break;
case 'handleWillEnterForeground':
rust.handleInterruptionEnded(shouldResume: true);
break;
case 'handleWillTerminate':
rust.handleInterruptionBegan();
break;
default:
break;
}
} catch (_) {
// Errors from the Rust side are already logged there; do not propagate
// exceptions to the platform framework.
}
});
}
/// Wire the Android audio lifecycle MethodChannel.
///
/// Kotlin side (`AndroidAudioLifecycleController`) posts route-change events
/// through this channel. This mirrors the iOS dispatch path.
void wireAndroidAudioLifecycle({
bool isAndroid = false,
MethodChannel channel = const MethodChannel(androidAudioLifecycleChannelName),
}) {
if (!isAndroid && !Platform.isAndroid) return;
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:
break;
}
} catch (_) {
// Errors from the Rust side are already logged there; do not propagate
// exceptions to the platform framework.
}
});
}
@@ -0,0 +1,39 @@
import '../l10n/generated/app_localizations.dart';
import '../src/rust/lib.dart' as rust_err;
/// Map a `voiceJoin` error to a localized user-facing message.
String channelJoinErrorMessage(AppL10n l10n, Object error) {
if (error is rust_err.BridgeError) {
return error.when(
invalidCommand: (msg) => l10n.channelJoinFailedGeneric(msg),
dnsFailed: (host, reason) =>
l10n.channelJoinFailedGeneric('$host: $reason'),
connection: (msg) => l10n.channelJoinFailedGeneric(msg),
notConnected: () => l10n.channelJoinFailedGeneric('not connected'),
alreadyConnected: () =>
l10n.channelJoinFailedGeneric('already connected'),
serverRejected: (code, message) {
switch (code) {
case 0x0001:
return l10n.channelJoinFailedTimeout;
case 0x0a08:
return l10n.channelJoinFailedPermission;
case 0x0302:
return l10n.channelJoinAlreadyIn;
case 0x030d:
return l10n.channelJoinFailedPassword;
case 0x0309:
return l10n.channelJoinFailedFull;
case 0x030a:
return l10n.channelJoinFailedFamilyFull;
case 0x030e:
return l10n.channelJoinFailedPrivate;
default:
return l10n.channelJoinFailedGeneric(message);
}
},
unmapped: (msg) => l10n.channelJoinFailedGeneric(msg),
);
}
return l10n.channelJoinFailedGeneric(error.toString());
}
@@ -0,0 +1,243 @@
/// Alignment requested by a TeamSpeak spacer channel tag.
enum SpacerAlignment {
/// Left-aligned spacer content.
left,
/// Right-aligned spacer content.
right,
/// Center-aligned spacer content.
center,
}
/// Built-in TeamSpeak spacer separator patterns.
enum SpacerSpecialType {
/// `___`
solidLine,
/// `---`
dashLine,
/// `...`
dotLine,
/// `-.-`
dashDotLine,
/// `-..`
dashDotDotLine,
}
/// Parsed form of a TeamSpeak spacer channel name.
class SpacerChannelNameParseResult {
/// Construct a spacer parse result.
const SpacerChannelNameParseResult({
required this.isSpacer,
required this.isValid,
required this.alignment,
required this.isRepeating,
required this.uniqueSuffix,
required this.text,
required this.specialType,
required this.isBlankSpacer,
this.reason,
});
/// True only when the name begins with a valid bracketed spacer tag.
final bool isSpacer;
/// True when the bracketed spacer tag follows the supported syntax.
final bool isValid;
/// Optional alignment flag. Null means the server/client default.
final SpacerAlignment? alignment;
/// True when `*` appears in the tag and the text should repeat.
final bool isRepeating;
/// The exact suffix after `Spacer` and before `]`.
final String uniqueSuffix;
/// The exact text after the closing `]`.
final String text;
/// Built-in separator type for special text values.
final SpacerSpecialType? specialType;
/// True for the known blank-looking right-aligned dot spacer.
final bool isBlankSpacer;
/// Parse error/reason for non-spacer or malformed names.
final String? reason;
}
/// Options for formatting a TeamSpeak spacer channel name.
class SpacerChannelNameFormatOptions {
/// Construct spacer formatting options.
const SpacerChannelNameFormatOptions({
this.alignment,
this.isRepeating = false,
this.uniqueSuffix = '',
this.text = '',
});
/// Optional alignment flag. Null means omit the alignment prefix.
final SpacerAlignment? alignment;
/// Whether to include the repeating `*` tag flag.
final bool isRepeating;
/// Uniqueness suffix to place after `Spacer`.
final String uniqueSuffix;
/// Text to place after the closing `]`.
final String text;
}
const _notSpacer = SpacerChannelNameParseResult(
isSpacer: false,
isValid: false,
alignment: null,
isRepeating: false,
uniqueSuffix: '',
text: '',
specialType: null,
isBlankSpacer: false,
reason: 'not a spacer channel name',
);
/// Return true when [name] begins with a valid bracketed spacer tag.
bool isSpacerChannelName(String name) => parseSpacerChannelName(name).isSpacer;
/// Parse a TeamSpeak spacer channel name.
///
/// Supported tag form is `[?Spacer#]Text`, parsed case-insensitively.
/// `?` may be `l`, `r`, or `c`; `*` may also appear in the tag to
/// mark repeating spacer content. The suffix and text are preserved
/// exactly as written.
SpacerChannelNameParseResult parseSpacerChannelName(String name) {
if (!name.startsWith('[')) return _notSpacer;
final close = name.indexOf(']');
if (close < 0) {
return _invalidSpacerName('missing closing bracket');
}
final tag = name.substring(1, close);
final text = name.substring(close + 1);
final match = RegExp(
r'^([lrc*]*)(spacer)(.*)$',
caseSensitive: false,
).firstMatch(tag);
if (match == null) {
if (tag.toLowerCase().contains('spacer')) {
return _invalidSpacerName('invalid spacer tag');
}
return _notSpacer;
}
final flags = match.group(1) ?? '';
final uniqueSuffix = match.group(3) ?? '';
final alignmentFlags = flags
.toLowerCase()
.split('')
.where((flag) => flag == 'l' || flag == 'r' || flag == 'c')
.toList();
final repeatingFlags = flags.split('').where((flag) => flag == '*').length;
if (alignmentFlags.length > 1) {
return _invalidSpacerName('multiple alignment flags');
}
if (repeatingFlags > 1) {
return _invalidSpacerName('multiple repeating flags');
}
final alignmentFlag = alignmentFlags.isEmpty ? null : alignmentFlags.first;
final alignment = switch (alignmentFlag) {
'l' => SpacerAlignment.left,
'r' => SpacerAlignment.right,
'c' => SpacerAlignment.center,
_ => null,
};
final specialType = _specialTypeForText(text);
return SpacerChannelNameParseResult(
isSpacer: true,
isValid: true,
alignment: alignment,
isRepeating: repeatingFlags == 1,
uniqueSuffix: uniqueSuffix,
text: text,
specialType: specialType,
isBlankSpacer: alignment == SpacerAlignment.right && text == '.',
);
}
/// Format a TeamSpeak spacer channel name deterministically.
///
/// The output uses canonical `Spacer` casing and places `*` before
/// the alignment flag when both are present.
String formatSpacerChannelName(SpacerChannelNameFormatOptions options) {
final repeatFlag = options.isRepeating ? '*' : '';
final alignFlag = switch (options.alignment) {
SpacerAlignment.left => 'l',
SpacerAlignment.right => 'r',
SpacerAlignment.center => 'c',
null => '',
};
return '[$repeatFlag${alignFlag}Spacer${options.uniqueSuffix}]${options.text}';
}
/// Convert a channel name into display text while preserving the
/// underlying channel entity and behavior.
String channelSpacerLabel(String raw, {int repeatColumns = 32}) {
final parsed = parseSpacerChannelName(raw);
if (!parsed.isValid) return raw;
if (parsed.isBlankSpacer) return '';
if (parsed.isRepeating) {
return _repeatSpacerText(parsed.text, repeatColumns);
}
return switch (parsed.specialType) {
SpacerSpecialType.solidLine => '────────',
SpacerSpecialType.dashLine => '╌╌╌╌╌╌╌╌',
SpacerSpecialType.dotLine => '········',
SpacerSpecialType.dashDotLine => '─╶─╶─╶─╶─╶─╶─╶─╶',
SpacerSpecialType.dashDotDotLine => '─╶╶─╶╶─╶╶─╶╶',
null => parsed.text,
};
}
SpacerChannelNameParseResult _invalidSpacerName(String reason) {
return SpacerChannelNameParseResult(
isSpacer: false,
isValid: false,
alignment: null,
isRepeating: false,
uniqueSuffix: '',
text: '',
specialType: null,
isBlankSpacer: false,
reason: reason,
);
}
SpacerSpecialType? _specialTypeForText(String text) {
return switch (text) {
'___' => SpacerSpecialType.solidLine,
'---' => SpacerSpecialType.dashLine,
'...' => SpacerSpecialType.dotLine,
'-.-' => SpacerSpecialType.dashDotLine,
'-..' => SpacerSpecialType.dashDotDotLine,
_ => null,
};
}
String _repeatSpacerText(String pattern, int repeatColumns) {
if (pattern.isEmpty) return '────────';
final buffer = StringBuffer();
while (buffer.length < repeatColumns) {
buffer.write(pattern);
}
return buffer.toString().substring(0, repeatColumns);
}
@@ -0,0 +1,79 @@
import 'package:flutter/material.dart';
import '../design/chanora_tokens.dart';
import '../l10n/generated/app_localizations.dart';
enum ConnectionPhase {
/// App is disconnected, no error.
idle,
/// Connecting to server.
connecting,
/// Connected but still synchronizing snapshot.
synchronizing,
/// Connected and ready.
connected,
/// Connection lost, auto-reconnecting.
reconnecting,
/// Explicitly disconnected by user.
disconnected,
}
extension ConnectionPhaseState on ConnectionPhase {
bool get isServerReachable =>
this == ConnectionPhase.connected ||
this == ConnectionPhase.synchronizing ||
this == ConnectionPhase.reconnecting;
bool get canOpenChat =>
this == ConnectionPhase.connected ||
this == ConnectionPhase.synchronizing;
bool get canDisconnect => canOpenChat;
ConnectionTokens tokens(ColorScheme colorScheme) {
switch (this) {
case ConnectionPhase.idle:
case ConnectionPhase.disconnected:
return ConnectionTokens.disconnected(colorScheme);
case ConnectionPhase.connecting:
return ConnectionTokens.connecting(colorScheme);
case ConnectionPhase.synchronizing:
return ConnectionTokens.synchronizing(colorScheme);
case ConnectionPhase.connected:
return ConnectionTokens.connected(colorScheme);
case ConnectionPhase.reconnecting:
return ConnectionTokens.reconnecting(colorScheme);
}
}
}
String connectionStatusText({
required ConnectionPhase phase,
required AppL10n l10n,
String? serverName,
String? lostReason,
int? reconnectAttempt,
int? reconnectDelay,
}) {
switch (phase) {
case ConnectionPhase.idle:
return l10n.statusIdle;
case ConnectionPhase.connecting:
return l10n.statusConnecting;
case ConnectionPhase.synchronizing:
return 'Synchronizing...';
case ConnectionPhase.connected:
return l10n.statusConnected(serverName ?? '');
case ConnectionPhase.reconnecting:
return reconnectAttempt != null
? l10n.statusReconnecting(reconnectAttempt, reconnectDelay ?? 0)
: l10n.statusConnectionLost(lostReason ?? '');
case ConnectionPhase.disconnected:
return l10n.statusIdle;
}
}
@@ -1,7 +1,5 @@
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;
@@ -71,13 +69,12 @@ Future<bool?> showLinkTrustDialog(BuildContext context, String domain) async {
height: 24,
child: Checkbox(
value: remember,
onChanged: (v) => setDialogState(() => remember = v ?? false),
onChanged: (v) =>
setDialogState(() => remember = v ?? false),
),
),
const SizedBox(width: 8),
const Flexible(
child: Text('Trust all links from this domain'),
),
const Flexible(child: Text('Trust all links from this domain')),
],
),
],
@@ -0,0 +1,65 @@
import '../src/rust/api.dart' as rust;
class OwnClientSnapshotState {
const OwnClientSnapshotState({
required this.channelId,
required this.inputMuted,
required this.outputMuted,
required this.talkPowerOk,
required this.talkPower,
required this.talkPowerGranted,
this.neededTalkPower,
});
final BigInt channelId;
final bool inputMuted;
final bool outputMuted;
final bool talkPowerOk;
final int talkPower;
final bool talkPowerGranted;
final int? neededTalkPower;
}
OwnClientSnapshotState? ownClientSnapshotState(rust.BridgeSnapshot snapshot) {
for (final client in snapshot.clients) {
if (client.id != snapshot.ownClientId) continue;
final neededTalkPower = _channelById(
snapshot,
client.channel,
)?.neededTalkPower;
return OwnClientSnapshotState(
channelId: client.channel,
inputMuted: client.inputMuted,
outputMuted: client.outputMuted,
talkPowerOk: _talkPowerOk(client, neededTalkPower),
talkPower: client.talkPower,
talkPowerGranted: client.talkPowerGranted,
neededTalkPower: neededTalkPower,
);
}
return null;
}
String snapshotChannelName(rust.BridgeSnapshot? snapshot, BigInt? channelId) {
if (snapshot == null || channelId == null) return '';
return _channelById(snapshot, channelId)?.name ?? '';
}
int? snapshotNeededTalkPower(rust.BridgeSnapshot? snapshot, BigInt? channelId) {
if (snapshot == null || channelId == null) return null;
return _channelById(snapshot, channelId)?.neededTalkPower;
}
rust.BridgeChannel? _channelById(
rust.BridgeSnapshot snapshot,
BigInt channelId,
) {
return snapshot.channels
.where((channel) => channel.id == channelId)
.firstOrNull;
}
bool _talkPowerOk(rust.BridgeClient client, int? neededTalkPower) {
if (client.talkPowerGranted) return true;
return neededTalkPower == null || client.talkPower >= neededTalkPower;
}
@@ -0,0 +1,92 @@
typedef Ts3ServerLinkHandler = Future<void> Function(Ts3ServerLink link);
class Ts3ServerLink {
const Ts3ServerLink({
required this.host,
required this.hostWithPort,
this.port,
this.nickname,
this.password,
this.channel,
this.cid,
this.channelPassword,
this.token,
this.addBookmark,
});
final String host;
final String hostWithPort;
final int? port;
final String? nickname;
final String? password;
final String? channel;
final String? cid;
final String? channelPassword;
final String? token;
final String? addBookmark;
}
Ts3ServerLink? parseTs3ServerLink(String rawUrl) {
final trimmed = rawUrl.trim();
if (!trimmed.toLowerCase().startsWith('ts3server://')) return null;
var body = trimmed.substring('ts3server://'.length);
body = body.replaceFirst(RegExp(r'/+$'), '');
if (body.isEmpty) return null;
final split = _splitAuthorityAndQuery(body);
final hostPart = Uri.decodeComponent(split.authority).trim();
if (hostPart.isEmpty) return null;
final params = split.query == null
? const <String, String>{}
: Uri.splitQueryString(split.query!);
final port = int.tryParse(params['port'] ?? '');
final hostWithPort = port == null || _hasExplicitPort(hostPart)
? hostPart
: '$hostPart:$port';
return Ts3ServerLink(
host: hostPart,
hostWithPort: hostWithPort,
port: port,
nickname: _emptyToNull(params['nickname']),
password: _emptyToNull(params['password']),
channel: _emptyToNull(params['channel']),
cid: _emptyToNull(params['cid']),
channelPassword: _emptyToNull(params['channelpassword']),
token: _emptyToNull(params['token']),
addBookmark: _emptyToNull(params['addbookmark']),
);
}
({String authority, String? query}) _splitAuthorityAndQuery(String body) {
final rawQueryIndex = body.indexOf('?');
if (rawQueryIndex >= 0) {
return (
authority: body.substring(0, rawQueryIndex),
query: body.substring(rawQueryIndex + 1),
);
}
final encodedQueryIndex = body.toLowerCase().indexOf('%3f');
if (encodedQueryIndex >= 0) {
return (
authority: body.substring(0, encodedQueryIndex),
query: Uri.decodeComponent(body.substring(encodedQueryIndex + 3)),
);
}
return (authority: body, query: null);
}
bool _hasExplicitPort(String host) {
final lastColon = host.lastIndexOf(':');
if (lastColon <= 0 || lastColon == host.length - 1) return false;
return int.tryParse(host.substring(lastColon + 1)) != null;
}
String? _emptyToNull(String? value) {
if (value == null || value.isEmpty) return null;
return value;
}
@@ -1,21 +1,15 @@
import 'package:shared_preferences/shared_preferences.dart';
class UiSettings {
const UiSettings({
this.host = '',
this.nickname = '',
this.showPokeDialogs = true,
});
const UiSettings({this.host = '', this.nickname = ''});
final String host;
final String nickname;
final bool showPokeDialogs;
}
class UiPreferencesService {
static const _hostKey = 'ui.host';
static const _nicknameKey = 'ui.nickname';
static const _showPokeDialogsKey = 'ui.show_poke_dialogs';
static const _permissionsExplainedKey = 'perms_explained';
const UiPreferencesService();
@@ -25,21 +19,13 @@ class UiPreferencesService {
return UiSettings(
host: prefs.getString(_hostKey) ?? '',
nickname: prefs.getString(_nicknameKey) ?? '',
showPokeDialogs: prefs.getBool(_showPokeDialogsKey) ?? true,
);
}
Future<void> saveSettings({
String? host,
String? nickname,
bool? showPokeDialogs,
}) async {
Future<void> saveSettings({String? host, String? nickname}) async {
final prefs = await SharedPreferences.getInstance();
if (host != null) await prefs.setString(_hostKey, host);
if (nickname != null) await prefs.setString(_nicknameKey, nickname);
if (showPokeDialogs != null) {
await prefs.setBool(_showPokeDialogsKey, showPokeDialogs);
}
}
Future<bool> hasExplainedPermissions() async {
+220 -29
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`, `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`, `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`, `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`, `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,15 +46,6 @@ Future<bool> isConnected() => RustLib.instance.api.crateApiIsConnected();
void handleRouteChange({required BridgeAudioRoute route}) =>
RustLib.instance.api.crateApiHandleRouteChange(route: route);
/// 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.
///
@@ -74,11 +65,11 @@ void handleInterruptionEnded({required bool shouldResume}) => RustLib
.api
.crateApiHandleInterruptionEnded(shouldResume: shouldResume);
/// Set the push-to-talk state.
/// Set the focused/on-screen push-to-talk hold state.
///
/// Superseded in v1 by [`set_transmit_mode`] + the binding capture
/// dialog. Retained so legacy callers and integration tests keep
/// working; the new VoiceBar UI no longer invokes this.
/// Binding capture chooses which physical key drives PTT, while this
/// command carries the actual press/release edge for fallback focused
/// keyboard handling and touch controls.
Future<void> setPtt({required bool active}) =>
RustLib.instance.api.crateApiSetPtt(active: active);
@@ -131,19 +122,17 @@ Future<void> setPttBinding({
platformKey: platformKey,
);
/// Read the current PTT capability descriptor. Returns a
/// `(level, backend_id, bound_input_class)` triple matching the
/// privacy-safe `BridgeEvent::PttCapability` event shape; useful
/// for the initial UI render before the first event arrives.
Future<(String, String, String)> pttDescriptor() =>
/// Read the current PTT capability descriptor. Matches the privacy-safe
/// `BridgeEvent::PttCapability` event shape; useful for the initial UI
/// render before the first event arrives.
Future<BridgePttDescriptor> pttDescriptor() =>
RustLib.instance.api.crateApiPttDescriptor();
/// Return the persisted PTT binding as a
/// `(input_class, platform_key)` pair so the UI can hydrate its
/// display state at launch (e.g. show "PTT: Space" next to the
/// badge before the user re-opens the binding dialog). Empty
/// strings mean no binding has been persisted yet.
Future<(String, String)> getPttBinding() =>
/// Return the persisted PTT binding so the UI can hydrate its display
/// state at launch (e.g. show "PTT: Space" next to the badge before the
/// user re-opens the binding dialog). Empty strings mean no binding has
/// been persisted yet.
Future<BridgePttBinding> getPttBinding() =>
RustLib.instance.api.crateApiGetPttBinding();
/// Move our own client to `channel_id`. Optional channel password
@@ -176,6 +165,27 @@ Future<void> setOutputMuted({required bool muted}) =>
Future<void> setOutputGain({required double gain}) =>
RustLib.instance.api.crateApiSetOutputGain(gain: gain);
/// Set per-client output volume (SRS-075). `1.0` is unity, `0.0`
/// mutes. No-op when client has no active voice queue. Volume is
/// applied directly to the tsclientlib AudioQueue and takes effect
/// immediately on the next render callback.
Future<void> setClientVolume({
required BigInt clientId,
required double volume,
}) => RustLib.instance.api.crateApiSetClientVolume(
clientId: clientId,
volume: volume,
);
/// Send a text message to the specified target.
Future<void> sendChatMessage({
required String message,
required BridgeMessageTarget target,
}) => RustLib.instance.api.crateApiSendChatMessage(
message: message,
target: target,
);
/// User-initiated diagnostic export. Returns a multi-line text
/// blob, redacted per the production policy, that the user can
/// share or copy. DEC-016 forbids automatic uploads — this is the
@@ -256,6 +266,19 @@ Future<BridgeAudioProcessingConfig> getAudioProcessingConfig() =>
Future<BridgeAudioProcessingStats> audioProcessingStats() =>
RustLib.instance.api.crateApiAudioProcessingStats();
/// List available audio input and output devices from the platform.
Future<BridgeAudioDeviceList> listAudioDevices() =>
RustLib.instance.api.crateApiListAudioDevices();
/// Set the preferred input device by name. Takes effect on next
/// `start_audio`.
Future<void> setInputDevice({String? name}) =>
RustLib.instance.api.crateApiSetInputDevice(name: name);
/// Set the preferred output device by name.
Future<void> setOutputDevice({String? name}) =>
RustLib.instance.api.crateApiSetOutputDevice(name: name);
/// Configure the VAD model path.
Future<void> setVadModelPath({required String path}) =>
RustLib.instance.api.crateApiSetVadModelPath(path: path);
@@ -273,6 +296,14 @@ Future<void> setIosVoiceProcessingMode({
required BridgeIosVoiceProcessingMode mode,
}) => RustLib.instance.api.crateApiSetIosVoiceProcessingMode(mode: mode);
/// Set the preferred audio output route (Android/iOS).
void setAudioOutputRoute({required BridgeAudioRoute route}) =>
RustLib.instance.api.crateApiSetAudioOutputRoute(route: route);
/// Called from Flutter when the app enters background/foreground.
void recordLifecycleEvent({required String state}) =>
RustLib.instance.api.crateApiRecordLifecycleEvent(state: state);
/// Bridge processing backend.
enum BridgeAudioBackend {
/// Platform voice processing.
@@ -288,6 +319,53 @@ enum BridgeAudioBackend {
noop,
}
/// Audio device info from the platform.
class BridgeAudioDevice {
/// Human-readable device name.
final String name;
/// True if the OS reports this as the default device.
final bool isDefault;
const BridgeAudioDevice({required this.name, required this.isDefault});
@override
int get hashCode => name.hashCode ^ isDefault.hashCode;
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is BridgeAudioDevice &&
runtimeType == other.runtimeType &&
name == other.name &&
isDefault == other.isDefault;
}
/// List of available audio devices.
class BridgeAudioDeviceList {
/// Available input devices.
final List<BridgeAudioDevice> inputDevices;
/// Available output devices.
final List<BridgeAudioDevice> outputDevices;
const BridgeAudioDeviceList({
required this.inputDevices,
required this.outputDevices,
});
@override
int get hashCode => inputDevices.hashCode ^ outputDevices.hashCode;
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is BridgeAudioDeviceList &&
runtimeType == other.runtimeType &&
inputDevices == other.inputDevices &&
outputDevices == other.outputDevices;
}
/// P1 audio-processing configuration DTO.
class BridgeAudioProcessingConfig {
/// Route class.
@@ -628,12 +706,17 @@ class BridgeChannel {
/// True when the server marks the channel as password-protected.
final bool hasPassword;
/// Talk power threshold required to speak in this channel.
/// None means no talk-power restriction.
final int? neededTalkPower;
const BridgeChannel({
required this.id,
required this.parent,
required this.name,
required this.order,
required this.hasPassword,
this.neededTalkPower,
});
@override
@@ -642,7 +725,8 @@ class BridgeChannel {
parent.hashCode ^
name.hashCode ^
order.hashCode ^
hasPassword.hashCode;
hasPassword.hashCode ^
neededTalkPower.hashCode;
@override
bool operator ==(Object other) =>
@@ -653,7 +737,8 @@ class BridgeChannel {
parent == other.parent &&
name == other.name &&
order == other.order &&
hasPassword == other.hasPassword;
hasPassword == other.hasPassword &&
neededTalkPower == other.neededTalkPower;
}
/// Client as seen by Dart.
@@ -679,6 +764,12 @@ class BridgeClient {
/// True for TeamSpeak ServerQuery clients.
final bool isServerQuery;
/// Current talk power value assigned by the server.
final int talkPower;
/// True when the server has granted talk power regardless of numeric value.
final bool talkPowerGranted;
const BridgeClient({
required this.id,
required this.channel,
@@ -687,6 +778,8 @@ class BridgeClient {
required this.outputMuted,
required this.isSpeaking,
required this.isServerQuery,
required this.talkPower,
required this.talkPowerGranted,
});
@override
@@ -697,7 +790,9 @@ class BridgeClient {
inputMuted.hashCode ^
outputMuted.hashCode ^
isSpeaking.hashCode ^
isServerQuery.hashCode;
isServerQuery.hashCode ^
talkPower.hashCode ^
talkPowerGranted.hashCode;
@override
bool operator ==(Object other) =>
@@ -710,7 +805,9 @@ class BridgeClient {
inputMuted == other.inputMuted &&
outputMuted == other.outputMuted &&
isSpeaking == other.isSpeaking &&
isServerQuery == other.isServerQuery;
isServerQuery == other.isServerQuery &&
talkPower == other.talkPower &&
talkPowerGranted == other.talkPowerGranted;
}
/// Bridge effect owner for AEC/NS/AGC.
@@ -863,6 +960,27 @@ sealed class BridgeEvent with _$BridgeEvent {
/// Resolved permission state.
required PermissionStateKind state,
}) = BridgeEvent_PermissionState;
/// A text message received from the server.
const factory BridgeEvent.chatMessage({
/// Client id of the sender.
required BigInt senderId,
/// Nickname of the sender.
required String senderName,
/// Message content.
required String message,
/// Target scope (server/channel/private/poke).
required BridgeMessageTarget target,
}) = BridgeEvent_ChatMessage;
/// Audio route changed (speaker/earpiece/BT/wired).
const factory BridgeEvent.audioRouteChanged({
/// The new audio route.
required BridgeAudioRoute route,
}) = BridgeEvent_AudioRouteChanged;
}
/// Bridge iOS voice-processing mode.
@@ -874,6 +992,25 @@ enum BridgeIosVoiceProcessingMode {
sonoraExperimental,
}
@freezed
sealed class BridgeMessageTarget with _$BridgeMessageTarget {
const BridgeMessageTarget._();
/// Broadcast to entire server.
const factory BridgeMessageTarget.server() = BridgeMessageTarget_Server;
/// Broadcast to current channel.
const factory BridgeMessageTarget.channel() = BridgeMessageTarget_Channel;
/// Private message to a specific client.
const factory BridgeMessageTarget.client(BigInt field0) =
BridgeMessageTarget_Client;
/// Poke a specific client.
const factory BridgeMessageTarget.poke(BigInt field0) =
BridgeMessageTarget_Poke;
}
/// Coarse OS-reported network state. Mirrors
/// [`chanora_core::NetworkState`] across the bridge.
enum BridgeNetworkState {
@@ -887,6 +1024,60 @@ enum BridgeNetworkState {
offline,
}
/// Persisted PTT binding display state for the UI.
class BridgePttBinding {
/// Stable input category string (`""`, `"keyboard"`, or
/// `"mouse-side-button"`).
final String inputClass;
/// Display-only key label; empty when no binding is active.
final String keyLabel;
const BridgePttBinding({required this.inputClass, required this.keyLabel});
@override
int get hashCode => inputClass.hashCode ^ keyLabel.hashCode;
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is BridgePttBinding &&
runtimeType == other.runtimeType &&
inputClass == other.inputClass &&
keyLabel == other.keyLabel;
}
/// Privacy-safe PTT capability descriptor for the UI.
class BridgePttDescriptor {
/// Stable capability level name.
final String level;
/// Stable backend identifier.
final String backendId;
/// Coarse bound input class; empty when no binding is active.
final String boundInputClass;
const BridgePttDescriptor({
required this.level,
required this.backendId,
required this.boundInputClass,
});
@override
int get hashCode =>
level.hashCode ^ backendId.hashCode ^ boundInputClass.hashCode;
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is BridgePttDescriptor &&
runtimeType == other.runtimeType &&
level == other.level &&
backendId == other.backendId &&
boundInputClass == other.boundInputClass;
}
/// Coarse PTT input class (gen2 v0.9.3 / DEC-026). Stable strings;
/// the bridge never carries raw key codes.
enum BridgePttInputClass {
@@ -55,7 +55,7 @@ extension BridgeEventPatterns on BridgeEvent {
/// }
/// ```
@optionalTypeArgs TResult maybeMap<TResult extends Object?>({TResult Function( BridgeEvent_Connected value)? connected,TResult Function( BridgeEvent_Lost value)? lost,TResult Function( BridgeEvent_Reconnecting value)? reconnecting,TResult Function( BridgeEvent_Disconnected value)? disconnected,TResult Function( BridgeEvent_AudioStarted value)? audioStarted,TResult Function( BridgeEvent_AudioStopped value)? audioStopped,TResult Function( BridgeEvent_SnapshotChanged value)? snapshotChanged,TResult Function( BridgeEvent_PttCapability value)? pttCapability,TResult Function( BridgeEvent_VoiceState value)? voiceState,TResult Function( BridgeEvent_InterruptionState value)? interruptionState,TResult Function( BridgeEvent_PermissionState value)? permissionState,required TResult orElse(),}){
@optionalTypeArgs TResult maybeMap<TResult extends Object?>({TResult Function( BridgeEvent_Connected value)? connected,TResult Function( BridgeEvent_Lost value)? lost,TResult Function( BridgeEvent_Reconnecting value)? reconnecting,TResult Function( BridgeEvent_Disconnected value)? disconnected,TResult Function( BridgeEvent_AudioStarted value)? audioStarted,TResult Function( BridgeEvent_AudioStopped value)? audioStopped,TResult Function( BridgeEvent_SnapshotChanged value)? snapshotChanged,TResult Function( BridgeEvent_PttCapability value)? pttCapability,TResult Function( BridgeEvent_VoiceState value)? voiceState,TResult Function( BridgeEvent_InterruptionState value)? interruptionState,TResult Function( BridgeEvent_PermissionState value)? permissionState,TResult Function( BridgeEvent_ChatMessage value)? chatMessage,TResult Function( BridgeEvent_AudioRouteChanged value)? audioRouteChanged,required TResult orElse(),}){
final _that = this;
switch (_that) {
case BridgeEvent_Connected() when connected != null:
@@ -69,7 +69,9 @@ return snapshotChanged(_that);case BridgeEvent_PttCapability() when pttCapabilit
return pttCapability(_that);case BridgeEvent_VoiceState() when voiceState != null:
return voiceState(_that);case BridgeEvent_InterruptionState() when interruptionState != null:
return interruptionState(_that);case BridgeEvent_PermissionState() when permissionState != null:
return permissionState(_that);case _:
return permissionState(_that);case BridgeEvent_ChatMessage() when chatMessage != null:
return chatMessage(_that);case BridgeEvent_AudioRouteChanged() when audioRouteChanged != null:
return audioRouteChanged(_that);case _:
return orElse();
}
@@ -87,7 +89,7 @@ return permissionState(_that);case _:
/// }
/// ```
@optionalTypeArgs TResult map<TResult extends Object?>({required TResult Function( BridgeEvent_Connected value) connected,required TResult Function( BridgeEvent_Lost value) lost,required TResult Function( BridgeEvent_Reconnecting value) reconnecting,required TResult Function( BridgeEvent_Disconnected value) disconnected,required TResult Function( BridgeEvent_AudioStarted value) audioStarted,required TResult Function( BridgeEvent_AudioStopped value) audioStopped,required TResult Function( BridgeEvent_SnapshotChanged value) snapshotChanged,required TResult Function( BridgeEvent_PttCapability value) pttCapability,required TResult Function( BridgeEvent_VoiceState value) voiceState,required TResult Function( BridgeEvent_InterruptionState value) interruptionState,required TResult Function( BridgeEvent_PermissionState value) permissionState,}){
@optionalTypeArgs TResult map<TResult extends Object?>({required TResult Function( BridgeEvent_Connected value) connected,required TResult Function( BridgeEvent_Lost value) lost,required TResult Function( BridgeEvent_Reconnecting value) reconnecting,required TResult Function( BridgeEvent_Disconnected value) disconnected,required TResult Function( BridgeEvent_AudioStarted value) audioStarted,required TResult Function( BridgeEvent_AudioStopped value) audioStopped,required TResult Function( BridgeEvent_SnapshotChanged value) snapshotChanged,required TResult Function( BridgeEvent_PttCapability value) pttCapability,required TResult Function( BridgeEvent_VoiceState value) voiceState,required TResult Function( BridgeEvent_InterruptionState value) interruptionState,required TResult Function( BridgeEvent_PermissionState value) permissionState,required TResult Function( BridgeEvent_ChatMessage value) chatMessage,required TResult Function( BridgeEvent_AudioRouteChanged value) audioRouteChanged,}){
final _that = this;
switch (_that) {
case BridgeEvent_Connected():
@@ -101,7 +103,9 @@ return snapshotChanged(_that);case BridgeEvent_PttCapability():
return pttCapability(_that);case BridgeEvent_VoiceState():
return voiceState(_that);case BridgeEvent_InterruptionState():
return interruptionState(_that);case BridgeEvent_PermissionState():
return permissionState(_that);}
return permissionState(_that);case BridgeEvent_ChatMessage():
return chatMessage(_that);case BridgeEvent_AudioRouteChanged():
return audioRouteChanged(_that);}
}
/// A variant of `map` that fallback to returning `null`.
///
@@ -115,7 +119,7 @@ return permissionState(_that);}
/// }
/// ```
@optionalTypeArgs TResult? mapOrNull<TResult extends Object?>({TResult? Function( BridgeEvent_Connected value)? connected,TResult? Function( BridgeEvent_Lost value)? lost,TResult? Function( BridgeEvent_Reconnecting value)? reconnecting,TResult? Function( BridgeEvent_Disconnected value)? disconnected,TResult? Function( BridgeEvent_AudioStarted value)? audioStarted,TResult? Function( BridgeEvent_AudioStopped value)? audioStopped,TResult? Function( BridgeEvent_SnapshotChanged value)? snapshotChanged,TResult? Function( BridgeEvent_PttCapability value)? pttCapability,TResult? Function( BridgeEvent_VoiceState value)? voiceState,TResult? Function( BridgeEvent_InterruptionState value)? interruptionState,TResult? Function( BridgeEvent_PermissionState value)? permissionState,}){
@optionalTypeArgs TResult? mapOrNull<TResult extends Object?>({TResult? Function( BridgeEvent_Connected value)? connected,TResult? Function( BridgeEvent_Lost value)? lost,TResult? Function( BridgeEvent_Reconnecting value)? reconnecting,TResult? Function( BridgeEvent_Disconnected value)? disconnected,TResult? Function( BridgeEvent_AudioStarted value)? audioStarted,TResult? Function( BridgeEvent_AudioStopped value)? audioStopped,TResult? Function( BridgeEvent_SnapshotChanged value)? snapshotChanged,TResult? Function( BridgeEvent_PttCapability value)? pttCapability,TResult? Function( BridgeEvent_VoiceState value)? voiceState,TResult? Function( BridgeEvent_InterruptionState value)? interruptionState,TResult? Function( BridgeEvent_PermissionState value)? permissionState,TResult? Function( BridgeEvent_ChatMessage value)? chatMessage,TResult? Function( BridgeEvent_AudioRouteChanged value)? audioRouteChanged,}){
final _that = this;
switch (_that) {
case BridgeEvent_Connected() when connected != null:
@@ -129,7 +133,9 @@ return snapshotChanged(_that);case BridgeEvent_PttCapability() when pttCapabilit
return pttCapability(_that);case BridgeEvent_VoiceState() when voiceState != null:
return voiceState(_that);case BridgeEvent_InterruptionState() when interruptionState != null:
return interruptionState(_that);case BridgeEvent_PermissionState() when permissionState != null:
return permissionState(_that);case _:
return permissionState(_that);case BridgeEvent_ChatMessage() when chatMessage != null:
return chatMessage(_that);case BridgeEvent_AudioRouteChanged() when audioRouteChanged != null:
return audioRouteChanged(_that);case _:
return null;
}
@@ -146,7 +152,7 @@ return permissionState(_that);case _:
/// }
/// ```
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>({TResult Function( String serverName)? connected,TResult Function( String reason)? lost,TResult Function( int attempt, int delaySecs)? reconnecting,TResult Function( String reason)? disconnected,TResult Function()? audioStarted,TResult Function()? audioStopped,TResult Function( int channels, int clients)? snapshotChanged,TResult Function( String level, String backendId, String boundInputClass)? pttCapability,TResult Function( bool inChannel, BridgeTransmitMode transmitMode, bool mute, int releaseTailMs, BigInt? currentChannelId, BigInt? pendingTargetChannelId, bool canJoin, bool canLeave, BridgeVoiceJoinSyncState joinSyncState, BridgeVoiceJoinErrorCode? joinErrorCode)? voiceState,TResult Function( bool began, bool shouldResume)? interruptionState,TResult Function( String permission, PermissionStateKind state)? permissionState,required TResult orElse(),}) {final _that = this;
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>({TResult Function( String serverName)? connected,TResult Function( String reason)? lost,TResult Function( int attempt, int delaySecs)? reconnecting,TResult Function( String reason)? disconnected,TResult Function()? audioStarted,TResult Function()? audioStopped,TResult Function( int channels, int clients)? snapshotChanged,TResult Function( String level, String backendId, String boundInputClass)? pttCapability,TResult Function( bool inChannel, BridgeTransmitMode transmitMode, bool mute, int releaseTailMs, BigInt? currentChannelId, BigInt? pendingTargetChannelId, bool canJoin, bool canLeave, BridgeVoiceJoinSyncState joinSyncState, BridgeVoiceJoinErrorCode? joinErrorCode)? voiceState,TResult Function( bool began, bool shouldResume)? interruptionState,TResult Function( String permission, PermissionStateKind state)? permissionState,TResult Function( BigInt senderId, String senderName, String message, BridgeMessageTarget target)? chatMessage,TResult Function( BridgeAudioRoute route)? audioRouteChanged,required TResult orElse(),}) {final _that = this;
switch (_that) {
case BridgeEvent_Connected() when connected != null:
return connected(_that.serverName);case BridgeEvent_Lost() when lost != null:
@@ -159,7 +165,9 @@ return snapshotChanged(_that.channels,_that.clients);case BridgeEvent_PttCapabil
return pttCapability(_that.level,_that.backendId,_that.boundInputClass);case BridgeEvent_VoiceState() when voiceState != null:
return voiceState(_that.inChannel,_that.transmitMode,_that.mute,_that.releaseTailMs,_that.currentChannelId,_that.pendingTargetChannelId,_that.canJoin,_that.canLeave,_that.joinSyncState,_that.joinErrorCode);case BridgeEvent_InterruptionState() when interruptionState != null:
return interruptionState(_that.began,_that.shouldResume);case BridgeEvent_PermissionState() when permissionState != null:
return permissionState(_that.permission,_that.state);case _:
return permissionState(_that.permission,_that.state);case BridgeEvent_ChatMessage() when chatMessage != null:
return chatMessage(_that.senderId,_that.senderName,_that.message,_that.target);case BridgeEvent_AudioRouteChanged() when audioRouteChanged != null:
return audioRouteChanged(_that.route);case _:
return orElse();
}
@@ -177,7 +185,7 @@ return permissionState(_that.permission,_that.state);case _:
/// }
/// ```
@optionalTypeArgs TResult when<TResult extends Object?>({required TResult Function( String serverName) connected,required TResult Function( String reason) lost,required TResult Function( int attempt, int delaySecs) reconnecting,required TResult Function( String reason) disconnected,required TResult Function() audioStarted,required TResult Function() audioStopped,required TResult Function( int channels, int clients) snapshotChanged,required TResult Function( String level, String backendId, String boundInputClass) pttCapability,required TResult Function( bool inChannel, BridgeTransmitMode transmitMode, bool mute, int releaseTailMs, BigInt? currentChannelId, BigInt? pendingTargetChannelId, bool canJoin, bool canLeave, BridgeVoiceJoinSyncState joinSyncState, BridgeVoiceJoinErrorCode? joinErrorCode) voiceState,required TResult Function( bool began, bool shouldResume) interruptionState,required TResult Function( String permission, PermissionStateKind state) permissionState,}) {final _that = this;
@optionalTypeArgs TResult when<TResult extends Object?>({required TResult Function( String serverName) connected,required TResult Function( String reason) lost,required TResult Function( int attempt, int delaySecs) reconnecting,required TResult Function( String reason) disconnected,required TResult Function() audioStarted,required TResult Function() audioStopped,required TResult Function( int channels, int clients) snapshotChanged,required TResult Function( String level, String backendId, String boundInputClass) pttCapability,required TResult Function( bool inChannel, BridgeTransmitMode transmitMode, bool mute, int releaseTailMs, BigInt? currentChannelId, BigInt? pendingTargetChannelId, bool canJoin, bool canLeave, BridgeVoiceJoinSyncState joinSyncState, BridgeVoiceJoinErrorCode? joinErrorCode) voiceState,required TResult Function( bool began, bool shouldResume) interruptionState,required TResult Function( String permission, PermissionStateKind state) permissionState,required TResult Function( BigInt senderId, String senderName, String message, BridgeMessageTarget target) chatMessage,required TResult Function( BridgeAudioRoute route) audioRouteChanged,}) {final _that = this;
switch (_that) {
case BridgeEvent_Connected():
return connected(_that.serverName);case BridgeEvent_Lost():
@@ -190,7 +198,9 @@ return snapshotChanged(_that.channels,_that.clients);case BridgeEvent_PttCapabil
return pttCapability(_that.level,_that.backendId,_that.boundInputClass);case BridgeEvent_VoiceState():
return voiceState(_that.inChannel,_that.transmitMode,_that.mute,_that.releaseTailMs,_that.currentChannelId,_that.pendingTargetChannelId,_that.canJoin,_that.canLeave,_that.joinSyncState,_that.joinErrorCode);case BridgeEvent_InterruptionState():
return interruptionState(_that.began,_that.shouldResume);case BridgeEvent_PermissionState():
return permissionState(_that.permission,_that.state);}
return permissionState(_that.permission,_that.state);case BridgeEvent_ChatMessage():
return chatMessage(_that.senderId,_that.senderName,_that.message,_that.target);case BridgeEvent_AudioRouteChanged():
return audioRouteChanged(_that.route);}
}
/// A variant of `when` that fallback to returning `null`
///
@@ -204,7 +214,7 @@ return permissionState(_that.permission,_that.state);}
/// }
/// ```
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>({TResult? Function( String serverName)? connected,TResult? Function( String reason)? lost,TResult? Function( int attempt, int delaySecs)? reconnecting,TResult? Function( String reason)? disconnected,TResult? Function()? audioStarted,TResult? Function()? audioStopped,TResult? Function( int channels, int clients)? snapshotChanged,TResult? Function( String level, String backendId, String boundInputClass)? pttCapability,TResult? Function( bool inChannel, BridgeTransmitMode transmitMode, bool mute, int releaseTailMs, BigInt? currentChannelId, BigInt? pendingTargetChannelId, bool canJoin, bool canLeave, BridgeVoiceJoinSyncState joinSyncState, BridgeVoiceJoinErrorCode? joinErrorCode)? voiceState,TResult? Function( bool began, bool shouldResume)? interruptionState,TResult? Function( String permission, PermissionStateKind state)? permissionState,}) {final _that = this;
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>({TResult? Function( String serverName)? connected,TResult? Function( String reason)? lost,TResult? Function( int attempt, int delaySecs)? reconnecting,TResult? Function( String reason)? disconnected,TResult? Function()? audioStarted,TResult? Function()? audioStopped,TResult? Function( int channels, int clients)? snapshotChanged,TResult? Function( String level, String backendId, String boundInputClass)? pttCapability,TResult? Function( bool inChannel, BridgeTransmitMode transmitMode, bool mute, int releaseTailMs, BigInt? currentChannelId, BigInt? pendingTargetChannelId, bool canJoin, bool canLeave, BridgeVoiceJoinSyncState joinSyncState, BridgeVoiceJoinErrorCode? joinErrorCode)? voiceState,TResult? Function( bool began, bool shouldResume)? interruptionState,TResult? Function( String permission, PermissionStateKind state)? permissionState,TResult? Function( BigInt senderId, String senderName, String message, BridgeMessageTarget target)? chatMessage,TResult? Function( BridgeAudioRoute route)? audioRouteChanged,}) {final _that = this;
switch (_that) {
case BridgeEvent_Connected() when connected != null:
return connected(_that.serverName);case BridgeEvent_Lost() when lost != null:
@@ -217,7 +227,9 @@ return snapshotChanged(_that.channels,_that.clients);case BridgeEvent_PttCapabil
return pttCapability(_that.level,_that.backendId,_that.boundInputClass);case BridgeEvent_VoiceState() when voiceState != null:
return voiceState(_that.inChannel,_that.transmitMode,_that.mute,_that.releaseTailMs,_that.currentChannelId,_that.pendingTargetChannelId,_that.canJoin,_that.canLeave,_that.joinSyncState,_that.joinErrorCode);case BridgeEvent_InterruptionState() when interruptionState != null:
return interruptionState(_that.began,_that.shouldResume);case BridgeEvent_PermissionState() when permissionState != null:
return permissionState(_that.permission,_that.state);case _:
return permissionState(_that.permission,_that.state);case BridgeEvent_ChatMessage() when chatMessage != null:
return chatMessage(_that.senderId,_that.senderName,_that.message,_that.target);case BridgeEvent_AudioRouteChanged() when audioRouteChanged != null:
return audioRouteChanged(_that.route);case _:
return null;
}
@@ -230,7 +242,7 @@ return permissionState(_that.permission,_that.state);case _:
class BridgeEvent_Connected extends BridgeEvent {
const BridgeEvent_Connected({required this.serverName}): super._();
/// Server name reported by the server snapshot.
final String serverName;
@@ -297,7 +309,7 @@ as String,
class BridgeEvent_Lost extends BridgeEvent {
const BridgeEvent_Lost({required this.reason}): super._();
/// Reason classification from the protocol layer.
final String reason;
@@ -364,7 +376,7 @@ as String,
class BridgeEvent_Reconnecting extends BridgeEvent {
const BridgeEvent_Reconnecting({required this.attempt, required this.delaySecs}): super._();
/// 1-based attempt counter for the current outage.
final int attempt;
@@ -434,7 +446,7 @@ as int,
class BridgeEvent_Disconnected extends BridgeEvent {
const BridgeEvent_Disconnected({required this.reason}): super._();
/// Reason classification.
final String reason;
@@ -501,7 +513,7 @@ as String,
class BridgeEvent_AudioStarted extends BridgeEvent {
const BridgeEvent_AudioStarted(): super._();
@@ -533,7 +545,7 @@ String toString() {
class BridgeEvent_AudioStopped extends BridgeEvent {
const BridgeEvent_AudioStopped(): super._();
@@ -565,7 +577,7 @@ String toString() {
class BridgeEvent_SnapshotChanged extends BridgeEvent {
const BridgeEvent_SnapshotChanged({required this.channels, required this.clients}): super._();
/// Latest channel count.
final int channels;
@@ -635,7 +647,7 @@ as int,
class BridgeEvent_PttCapability extends BridgeEvent {
const BridgeEvent_PttCapability({required this.level, required this.backendId, required this.boundInputClass}): super._();
/// Stable capability identifier (`"L0Focused"`,
/// `"L1GlobalShortcut"`, `"L2GlobalHoldToTalk"`,
@@ -711,7 +723,7 @@ as String,
class BridgeEvent_VoiceState extends BridgeEvent {
const BridgeEvent_VoiceState({required this.inChannel, required this.transmitMode, required this.mute, required this.releaseTailMs, this.currentChannelId, this.pendingTargetChannelId, required this.canJoin, required this.canLeave, required this.joinSyncState, this.joinErrorCode}): super._();
/// True when the session is currently joined to a voice
/// channel and the audio engine is running.
@@ -806,7 +818,7 @@ as BridgeVoiceJoinErrorCode?,
class BridgeEvent_InterruptionState extends BridgeEvent {
const BridgeEvent_InterruptionState({required this.began, required this.shouldResume}): super._();
/// True when interruption began, false when it ended.
final bool began;
@@ -876,7 +888,7 @@ as bool,
class BridgeEvent_PermissionState extends BridgeEvent {
const BridgeEvent_PermissionState({required this.permission, required this.state}): super._();
/// Canonical Android permission identifier.
final String permission;
@@ -939,6 +951,526 @@ as PermissionStateKind,
}
}
/// @nodoc
class BridgeEvent_ChatMessage extends BridgeEvent {
const BridgeEvent_ChatMessage({required this.senderId, required this.senderName, required this.message, required this.target}): super._();
/// Client id of the sender.
final BigInt senderId;
/// Nickname of the sender.
final String senderName;
/// Message content.
final String message;
/// Target scope (server/channel/private/poke).
final BridgeMessageTarget target;
/// Create a copy of BridgeEvent
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
$BridgeEvent_ChatMessageCopyWith<BridgeEvent_ChatMessage> get copyWith => _$BridgeEvent_ChatMessageCopyWithImpl<BridgeEvent_ChatMessage>(this, _$identity);
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is BridgeEvent_ChatMessage&&(identical(other.senderId, senderId) || other.senderId == senderId)&&(identical(other.senderName, senderName) || other.senderName == senderName)&&(identical(other.message, message) || other.message == message)&&(identical(other.target, target) || other.target == target));
}
@override
int get hashCode => Object.hash(runtimeType,senderId,senderName,message,target);
@override
String toString() {
return 'BridgeEvent.chatMessage(senderId: $senderId, senderName: $senderName, message: $message, target: $target)';
}
}
/// @nodoc
abstract mixin class $BridgeEvent_ChatMessageCopyWith<$Res> implements $BridgeEventCopyWith<$Res> {
factory $BridgeEvent_ChatMessageCopyWith(BridgeEvent_ChatMessage value, $Res Function(BridgeEvent_ChatMessage) _then) = _$BridgeEvent_ChatMessageCopyWithImpl;
@useResult
$Res call({
BigInt senderId, String senderName, String message, BridgeMessageTarget target
});
$BridgeMessageTargetCopyWith<$Res> get target;
}
/// @nodoc
class _$BridgeEvent_ChatMessageCopyWithImpl<$Res>
implements $BridgeEvent_ChatMessageCopyWith<$Res> {
_$BridgeEvent_ChatMessageCopyWithImpl(this._self, this._then);
final BridgeEvent_ChatMessage _self;
final $Res Function(BridgeEvent_ChatMessage) _then;
/// Create a copy of BridgeEvent
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline') $Res call({Object? senderId = null,Object? senderName = null,Object? message = null,Object? target = null,}) {
return _then(BridgeEvent_ChatMessage(
senderId: null == senderId ? _self.senderId : senderId // ignore: cast_nullable_to_non_nullable
as BigInt,senderName: null == senderName ? _self.senderName : senderName // ignore: cast_nullable_to_non_nullable
as String,message: null == message ? _self.message : message // ignore: cast_nullable_to_non_nullable
as String,target: null == target ? _self.target : target // ignore: cast_nullable_to_non_nullable
as BridgeMessageTarget,
));
}
/// Create a copy of BridgeEvent
/// with the given fields replaced by the non-null parameter values.
@override
@pragma('vm:prefer-inline')
$BridgeMessageTargetCopyWith<$Res> get target {
return $BridgeMessageTargetCopyWith<$Res>(_self.target, (value) {
return _then(_self.copyWith(target: value));
});
}
}
/// @nodoc
class BridgeEvent_AudioRouteChanged extends BridgeEvent {
const BridgeEvent_AudioRouteChanged({required this.route}): super._();
/// The new audio route.
final BridgeAudioRoute route;
/// Create a copy of BridgeEvent
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
$BridgeEvent_AudioRouteChangedCopyWith<BridgeEvent_AudioRouteChanged> get copyWith => _$BridgeEvent_AudioRouteChangedCopyWithImpl<BridgeEvent_AudioRouteChanged>(this, _$identity);
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is BridgeEvent_AudioRouteChanged&&(identical(other.route, route) || other.route == route));
}
@override
int get hashCode => Object.hash(runtimeType,route);
@override
String toString() {
return 'BridgeEvent.audioRouteChanged(route: $route)';
}
}
/// @nodoc
abstract mixin class $BridgeEvent_AudioRouteChangedCopyWith<$Res> implements $BridgeEventCopyWith<$Res> {
factory $BridgeEvent_AudioRouteChangedCopyWith(BridgeEvent_AudioRouteChanged value, $Res Function(BridgeEvent_AudioRouteChanged) _then) = _$BridgeEvent_AudioRouteChangedCopyWithImpl;
@useResult
$Res call({
BridgeAudioRoute route
});
}
/// @nodoc
class _$BridgeEvent_AudioRouteChangedCopyWithImpl<$Res>
implements $BridgeEvent_AudioRouteChangedCopyWith<$Res> {
_$BridgeEvent_AudioRouteChangedCopyWithImpl(this._self, this._then);
final BridgeEvent_AudioRouteChanged _self;
final $Res Function(BridgeEvent_AudioRouteChanged) _then;
/// Create a copy of BridgeEvent
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline') $Res call({Object? route = null,}) {
return _then(BridgeEvent_AudioRouteChanged(
route: null == route ? _self.route : route // ignore: cast_nullable_to_non_nullable
as BridgeAudioRoute,
));
}
}
/// @nodoc
mixin _$BridgeMessageTarget {
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is BridgeMessageTarget);
}
@override
int get hashCode => runtimeType.hashCode;
@override
String toString() {
return 'BridgeMessageTarget()';
}
}
/// @nodoc
class $BridgeMessageTargetCopyWith<$Res> {
$BridgeMessageTargetCopyWith(BridgeMessageTarget _, $Res Function(BridgeMessageTarget) __);
}
/// Adds pattern-matching-related methods to [BridgeMessageTarget].
extension BridgeMessageTargetPatterns on BridgeMessageTarget {
/// A variant of `map` that fallback to returning `orElse`.
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case final Subclass value:
/// return ...;
/// case _:
/// return orElse();
/// }
/// ```
@optionalTypeArgs TResult maybeMap<TResult extends Object?>({TResult Function( BridgeMessageTarget_Server value)? server,TResult Function( BridgeMessageTarget_Channel value)? channel,TResult Function( BridgeMessageTarget_Client value)? client,TResult Function( BridgeMessageTarget_Poke value)? poke,required TResult orElse(),}){
final _that = this;
switch (_that) {
case BridgeMessageTarget_Server() when server != null:
return server(_that);case BridgeMessageTarget_Channel() when channel != null:
return channel(_that);case BridgeMessageTarget_Client() when client != null:
return client(_that);case BridgeMessageTarget_Poke() when poke != null:
return poke(_that);case _:
return orElse();
}
}
/// A `switch`-like method, using callbacks.
///
/// Callbacks receives the raw object, upcasted.
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case final Subclass value:
/// return ...;
/// case final Subclass2 value:
/// return ...;
/// }
/// ```
@optionalTypeArgs TResult map<TResult extends Object?>({required TResult Function( BridgeMessageTarget_Server value) server,required TResult Function( BridgeMessageTarget_Channel value) channel,required TResult Function( BridgeMessageTarget_Client value) client,required TResult Function( BridgeMessageTarget_Poke value) poke,}){
final _that = this;
switch (_that) {
case BridgeMessageTarget_Server():
return server(_that);case BridgeMessageTarget_Channel():
return channel(_that);case BridgeMessageTarget_Client():
return client(_that);case BridgeMessageTarget_Poke():
return poke(_that);}
}
/// A variant of `map` that fallback to returning `null`.
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case final Subclass value:
/// return ...;
/// case _:
/// return null;
/// }
/// ```
@optionalTypeArgs TResult? mapOrNull<TResult extends Object?>({TResult? Function( BridgeMessageTarget_Server value)? server,TResult? Function( BridgeMessageTarget_Channel value)? channel,TResult? Function( BridgeMessageTarget_Client value)? client,TResult? Function( BridgeMessageTarget_Poke value)? poke,}){
final _that = this;
switch (_that) {
case BridgeMessageTarget_Server() when server != null:
return server(_that);case BridgeMessageTarget_Channel() when channel != null:
return channel(_that);case BridgeMessageTarget_Client() when client != null:
return client(_that);case BridgeMessageTarget_Poke() when poke != null:
return poke(_that);case _:
return null;
}
}
/// A variant of `when` that fallback to an `orElse` callback.
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case Subclass(:final field):
/// return ...;
/// case _:
/// return orElse();
/// }
/// ```
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>({TResult Function()? server,TResult Function()? channel,TResult Function( BigInt field0)? client,TResult Function( BigInt field0)? poke,required TResult orElse(),}) {final _that = this;
switch (_that) {
case BridgeMessageTarget_Server() when server != null:
return server();case BridgeMessageTarget_Channel() when channel != null:
return channel();case BridgeMessageTarget_Client() when client != null:
return client(_that.field0);case BridgeMessageTarget_Poke() when poke != null:
return poke(_that.field0);case _:
return orElse();
}
}
/// A `switch`-like method, using callbacks.
///
/// As opposed to `map`, this offers destructuring.
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case Subclass(:final field):
/// return ...;
/// case Subclass2(:final field2):
/// return ...;
/// }
/// ```
@optionalTypeArgs TResult when<TResult extends Object?>({required TResult Function() server,required TResult Function() channel,required TResult Function( BigInt field0) client,required TResult Function( BigInt field0) poke,}) {final _that = this;
switch (_that) {
case BridgeMessageTarget_Server():
return server();case BridgeMessageTarget_Channel():
return channel();case BridgeMessageTarget_Client():
return client(_that.field0);case BridgeMessageTarget_Poke():
return poke(_that.field0);}
}
/// A variant of `when` that fallback to returning `null`
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case Subclass(:final field):
/// return ...;
/// case _:
/// return null;
/// }
/// ```
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>({TResult? Function()? server,TResult? Function()? channel,TResult? Function( BigInt field0)? client,TResult? Function( BigInt field0)? poke,}) {final _that = this;
switch (_that) {
case BridgeMessageTarget_Server() when server != null:
return server();case BridgeMessageTarget_Channel() when channel != null:
return channel();case BridgeMessageTarget_Client() when client != null:
return client(_that.field0);case BridgeMessageTarget_Poke() when poke != null:
return poke(_that.field0);case _:
return null;
}
}
}
/// @nodoc
class BridgeMessageTarget_Server extends BridgeMessageTarget {
const BridgeMessageTarget_Server(): super._();
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is BridgeMessageTarget_Server);
}
@override
int get hashCode => runtimeType.hashCode;
@override
String toString() {
return 'BridgeMessageTarget.server()';
}
}
/// @nodoc
class BridgeMessageTarget_Channel extends BridgeMessageTarget {
const BridgeMessageTarget_Channel(): super._();
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is BridgeMessageTarget_Channel);
}
@override
int get hashCode => runtimeType.hashCode;
@override
String toString() {
return 'BridgeMessageTarget.channel()';
}
}
/// @nodoc
class BridgeMessageTarget_Client extends BridgeMessageTarget {
const BridgeMessageTarget_Client(this.field0): super._();
final BigInt field0;
/// Create a copy of BridgeMessageTarget
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
$BridgeMessageTarget_ClientCopyWith<BridgeMessageTarget_Client> get copyWith => _$BridgeMessageTarget_ClientCopyWithImpl<BridgeMessageTarget_Client>(this, _$identity);
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is BridgeMessageTarget_Client&&(identical(other.field0, field0) || other.field0 == field0));
}
@override
int get hashCode => Object.hash(runtimeType,field0);
@override
String toString() {
return 'BridgeMessageTarget.client(field0: $field0)';
}
}
/// @nodoc
abstract mixin class $BridgeMessageTarget_ClientCopyWith<$Res> implements $BridgeMessageTargetCopyWith<$Res> {
factory $BridgeMessageTarget_ClientCopyWith(BridgeMessageTarget_Client value, $Res Function(BridgeMessageTarget_Client) _then) = _$BridgeMessageTarget_ClientCopyWithImpl;
@useResult
$Res call({
BigInt field0
});
}
/// @nodoc
class _$BridgeMessageTarget_ClientCopyWithImpl<$Res>
implements $BridgeMessageTarget_ClientCopyWith<$Res> {
_$BridgeMessageTarget_ClientCopyWithImpl(this._self, this._then);
final BridgeMessageTarget_Client _self;
final $Res Function(BridgeMessageTarget_Client) _then;
/// Create a copy of BridgeMessageTarget
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline') $Res call({Object? field0 = null,}) {
return _then(BridgeMessageTarget_Client(
null == field0 ? _self.field0 : field0 // ignore: cast_nullable_to_non_nullable
as BigInt,
));
}
}
/// @nodoc
class BridgeMessageTarget_Poke extends BridgeMessageTarget {
const BridgeMessageTarget_Poke(this.field0): super._();
final BigInt field0;
/// Create a copy of BridgeMessageTarget
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
$BridgeMessageTarget_PokeCopyWith<BridgeMessageTarget_Poke> get copyWith => _$BridgeMessageTarget_PokeCopyWithImpl<BridgeMessageTarget_Poke>(this, _$identity);
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is BridgeMessageTarget_Poke&&(identical(other.field0, field0) || other.field0 == field0));
}
@override
int get hashCode => Object.hash(runtimeType,field0);
@override
String toString() {
return 'BridgeMessageTarget.poke(field0: $field0)';
}
}
/// @nodoc
abstract mixin class $BridgeMessageTarget_PokeCopyWith<$Res> implements $BridgeMessageTargetCopyWith<$Res> {
factory $BridgeMessageTarget_PokeCopyWith(BridgeMessageTarget_Poke value, $Res Function(BridgeMessageTarget_Poke) _then) = _$BridgeMessageTarget_PokeCopyWithImpl;
@useResult
$Res call({
BigInt field0
});
}
/// @nodoc
class _$BridgeMessageTarget_PokeCopyWithImpl<$Res>
implements $BridgeMessageTarget_PokeCopyWith<$Res> {
_$BridgeMessageTarget_PokeCopyWithImpl(this._self, this._then);
final BridgeMessageTarget_Poke _self;
final $Res Function(BridgeMessageTarget_Poke) _then;
/// Create a copy of BridgeMessageTarget
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline') $Res call({Object? field0 = null,}) {
return _then(BridgeMessageTarget_Poke(
null == field0 ? _self.field0 : field0 // ignore: cast_nullable_to_non_nullable
as BigInt,
));
}
}
// dart format on
File diff suppressed because it is too large Load Diff
@@ -40,17 +40,29 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
@protected
BridgeBookmark dco_decode_box_autoadd_bridge_bookmark(dynamic raw);
@protected
BridgeMessageTarget dco_decode_box_autoadd_bridge_message_target(dynamic raw);
@protected
BridgeVoiceJoinErrorCode dco_decode_box_autoadd_bridge_voice_join_error_code(
dynamic raw,
);
@protected
int dco_decode_box_autoadd_i_32(dynamic raw);
@protected
BigInt dco_decode_box_autoadd_u_64(dynamic raw);
@protected
BridgeAudioBackend dco_decode_bridge_audio_backend(dynamic raw);
@protected
BridgeAudioDevice dco_decode_bridge_audio_device(dynamic raw);
@protected
BridgeAudioDeviceList dco_decode_bridge_audio_device_list(dynamic raw);
@protected
BridgeAudioProcessingConfig dco_decode_bridge_audio_processing_config(
dynamic raw,
@@ -90,9 +102,18 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
dynamic raw,
);
@protected
BridgeMessageTarget dco_decode_bridge_message_target(dynamic raw);
@protected
BridgeNetworkState dco_decode_bridge_network_state(dynamic raw);
@protected
BridgePttBinding dco_decode_bridge_ptt_binding(dynamic raw);
@protected
BridgePttDescriptor dco_decode_bridge_ptt_descriptor(dynamic raw);
@protected
BridgePttInputClass dco_decode_bridge_ptt_input_class(dynamic raw);
@@ -120,6 +141,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
@protected
PlatformInt64 dco_decode_i_64(dynamic raw);
@protected
List<BridgeAudioDevice> dco_decode_list_bridge_audio_device(dynamic raw);
@protected
List<BridgeBookmark> dco_decode_list_bridge_bookmark(dynamic raw);
@@ -132,22 +156,22 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
@protected
Uint8List dco_decode_list_prim_u_8_strict(dynamic raw);
@protected
String? dco_decode_opt_String(dynamic raw);
@protected
BridgeVoiceJoinErrorCode?
dco_decode_opt_box_autoadd_bridge_voice_join_error_code(dynamic raw);
@protected
int? dco_decode_opt_box_autoadd_i_32(dynamic raw);
@protected
BigInt? dco_decode_opt_box_autoadd_u_64(dynamic raw);
@protected
PermissionStateKind dco_decode_permission_state_kind(dynamic raw);
@protected
(String, String) dco_decode_record_string_string(dynamic raw);
@protected
(String, String, String) dco_decode_record_string_string_string(dynamic raw);
@protected
int dco_decode_u_32(dynamic raw);
@@ -185,11 +209,19 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
SseDeserializer deserializer,
);
@protected
BridgeMessageTarget sse_decode_box_autoadd_bridge_message_target(
SseDeserializer deserializer,
);
@protected
BridgeVoiceJoinErrorCode sse_decode_box_autoadd_bridge_voice_join_error_code(
SseDeserializer deserializer,
);
@protected
int sse_decode_box_autoadd_i_32(SseDeserializer deserializer);
@protected
BigInt sse_decode_box_autoadd_u_64(SseDeserializer deserializer);
@@ -198,6 +230,16 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
SseDeserializer deserializer,
);
@protected
BridgeAudioDevice sse_decode_bridge_audio_device(
SseDeserializer deserializer,
);
@protected
BridgeAudioDeviceList sse_decode_bridge_audio_device_list(
SseDeserializer deserializer,
);
@protected
BridgeAudioProcessingConfig sse_decode_bridge_audio_processing_config(
SseDeserializer deserializer,
@@ -239,11 +281,24 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
SseDeserializer deserializer,
);
@protected
BridgeMessageTarget sse_decode_bridge_message_target(
SseDeserializer deserializer,
);
@protected
BridgeNetworkState sse_decode_bridge_network_state(
SseDeserializer deserializer,
);
@protected
BridgePttBinding sse_decode_bridge_ptt_binding(SseDeserializer deserializer);
@protected
BridgePttDescriptor sse_decode_bridge_ptt_descriptor(
SseDeserializer deserializer,
);
@protected
BridgePttInputClass sse_decode_bridge_ptt_input_class(
SseDeserializer deserializer,
@@ -279,6 +334,11 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
@protected
PlatformInt64 sse_decode_i_64(SseDeserializer deserializer);
@protected
List<BridgeAudioDevice> sse_decode_list_bridge_audio_device(
SseDeserializer deserializer,
);
@protected
List<BridgeBookmark> sse_decode_list_bridge_bookmark(
SseDeserializer deserializer,
@@ -297,12 +357,18 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
@protected
Uint8List sse_decode_list_prim_u_8_strict(SseDeserializer deserializer);
@protected
String? sse_decode_opt_String(SseDeserializer deserializer);
@protected
BridgeVoiceJoinErrorCode?
sse_decode_opt_box_autoadd_bridge_voice_join_error_code(
SseDeserializer deserializer,
);
@protected
int? sse_decode_opt_box_autoadd_i_32(SseDeserializer deserializer);
@protected
BigInt? sse_decode_opt_box_autoadd_u_64(SseDeserializer deserializer);
@@ -311,16 +377,6 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
SseDeserializer deserializer,
);
@protected
(String, String) sse_decode_record_string_string(
SseDeserializer deserializer,
);
@protected
(String, String, String) sse_decode_record_string_string_string(
SseDeserializer deserializer,
);
@protected
int sse_decode_u_32(SseDeserializer deserializer);
@@ -363,12 +419,21 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
SseSerializer serializer,
);
@protected
void sse_encode_box_autoadd_bridge_message_target(
BridgeMessageTarget self,
SseSerializer serializer,
);
@protected
void sse_encode_box_autoadd_bridge_voice_join_error_code(
BridgeVoiceJoinErrorCode self,
SseSerializer serializer,
);
@protected
void sse_encode_box_autoadd_i_32(int self, SseSerializer serializer);
@protected
void sse_encode_box_autoadd_u_64(BigInt self, SseSerializer serializer);
@@ -378,6 +443,18 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
SseSerializer serializer,
);
@protected
void sse_encode_bridge_audio_device(
BridgeAudioDevice self,
SseSerializer serializer,
);
@protected
void sse_encode_bridge_audio_device_list(
BridgeAudioDeviceList self,
SseSerializer serializer,
);
@protected
void sse_encode_bridge_audio_processing_config(
BridgeAudioProcessingConfig self,
@@ -432,12 +509,30 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
SseSerializer serializer,
);
@protected
void sse_encode_bridge_message_target(
BridgeMessageTarget self,
SseSerializer serializer,
);
@protected
void sse_encode_bridge_network_state(
BridgeNetworkState self,
SseSerializer serializer,
);
@protected
void sse_encode_bridge_ptt_binding(
BridgePttBinding self,
SseSerializer serializer,
);
@protected
void sse_encode_bridge_ptt_descriptor(
BridgePttDescriptor self,
SseSerializer serializer,
);
@protected
void sse_encode_bridge_ptt_input_class(
BridgePttInputClass self,
@@ -483,6 +578,12 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
@protected
void sse_encode_i_64(PlatformInt64 self, SseSerializer serializer);
@protected
void sse_encode_list_bridge_audio_device(
List<BridgeAudioDevice> self,
SseSerializer serializer,
);
@protected
void sse_encode_list_bridge_bookmark(
List<BridgeBookmark> self,
@@ -507,12 +608,18 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
SseSerializer serializer,
);
@protected
void sse_encode_opt_String(String? self, SseSerializer serializer);
@protected
void sse_encode_opt_box_autoadd_bridge_voice_join_error_code(
BridgeVoiceJoinErrorCode? self,
SseSerializer serializer,
);
@protected
void sse_encode_opt_box_autoadd_i_32(int? self, SseSerializer serializer);
@protected
void sse_encode_opt_box_autoadd_u_64(BigInt? self, SseSerializer serializer);
@@ -522,18 +629,6 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
SseSerializer serializer,
);
@protected
void sse_encode_record_string_string(
(String, String) self,
SseSerializer serializer,
);
@protected
void sse_encode_record_string_string_string(
(String, String, String) self,
SseSerializer serializer,
);
@protected
void sse_encode_u_32(int self, SseSerializer serializer);
@@ -42,17 +42,29 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
@protected
BridgeBookmark dco_decode_box_autoadd_bridge_bookmark(dynamic raw);
@protected
BridgeMessageTarget dco_decode_box_autoadd_bridge_message_target(dynamic raw);
@protected
BridgeVoiceJoinErrorCode dco_decode_box_autoadd_bridge_voice_join_error_code(
dynamic raw,
);
@protected
int dco_decode_box_autoadd_i_32(dynamic raw);
@protected
BigInt dco_decode_box_autoadd_u_64(dynamic raw);
@protected
BridgeAudioBackend dco_decode_bridge_audio_backend(dynamic raw);
@protected
BridgeAudioDevice dco_decode_bridge_audio_device(dynamic raw);
@protected
BridgeAudioDeviceList dco_decode_bridge_audio_device_list(dynamic raw);
@protected
BridgeAudioProcessingConfig dco_decode_bridge_audio_processing_config(
dynamic raw,
@@ -92,9 +104,18 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
dynamic raw,
);
@protected
BridgeMessageTarget dco_decode_bridge_message_target(dynamic raw);
@protected
BridgeNetworkState dco_decode_bridge_network_state(dynamic raw);
@protected
BridgePttBinding dco_decode_bridge_ptt_binding(dynamic raw);
@protected
BridgePttDescriptor dco_decode_bridge_ptt_descriptor(dynamic raw);
@protected
BridgePttInputClass dco_decode_bridge_ptt_input_class(dynamic raw);
@@ -122,6 +143,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
@protected
PlatformInt64 dco_decode_i_64(dynamic raw);
@protected
List<BridgeAudioDevice> dco_decode_list_bridge_audio_device(dynamic raw);
@protected
List<BridgeBookmark> dco_decode_list_bridge_bookmark(dynamic raw);
@@ -134,22 +158,22 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
@protected
Uint8List dco_decode_list_prim_u_8_strict(dynamic raw);
@protected
String? dco_decode_opt_String(dynamic raw);
@protected
BridgeVoiceJoinErrorCode?
dco_decode_opt_box_autoadd_bridge_voice_join_error_code(dynamic raw);
@protected
int? dco_decode_opt_box_autoadd_i_32(dynamic raw);
@protected
BigInt? dco_decode_opt_box_autoadd_u_64(dynamic raw);
@protected
PermissionStateKind dco_decode_permission_state_kind(dynamic raw);
@protected
(String, String) dco_decode_record_string_string(dynamic raw);
@protected
(String, String, String) dco_decode_record_string_string_string(dynamic raw);
@protected
int dco_decode_u_32(dynamic raw);
@@ -187,11 +211,19 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
SseDeserializer deserializer,
);
@protected
BridgeMessageTarget sse_decode_box_autoadd_bridge_message_target(
SseDeserializer deserializer,
);
@protected
BridgeVoiceJoinErrorCode sse_decode_box_autoadd_bridge_voice_join_error_code(
SseDeserializer deserializer,
);
@protected
int sse_decode_box_autoadd_i_32(SseDeserializer deserializer);
@protected
BigInt sse_decode_box_autoadd_u_64(SseDeserializer deserializer);
@@ -200,6 +232,16 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
SseDeserializer deserializer,
);
@protected
BridgeAudioDevice sse_decode_bridge_audio_device(
SseDeserializer deserializer,
);
@protected
BridgeAudioDeviceList sse_decode_bridge_audio_device_list(
SseDeserializer deserializer,
);
@protected
BridgeAudioProcessingConfig sse_decode_bridge_audio_processing_config(
SseDeserializer deserializer,
@@ -241,11 +283,24 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
SseDeserializer deserializer,
);
@protected
BridgeMessageTarget sse_decode_bridge_message_target(
SseDeserializer deserializer,
);
@protected
BridgeNetworkState sse_decode_bridge_network_state(
SseDeserializer deserializer,
);
@protected
BridgePttBinding sse_decode_bridge_ptt_binding(SseDeserializer deserializer);
@protected
BridgePttDescriptor sse_decode_bridge_ptt_descriptor(
SseDeserializer deserializer,
);
@protected
BridgePttInputClass sse_decode_bridge_ptt_input_class(
SseDeserializer deserializer,
@@ -281,6 +336,11 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
@protected
PlatformInt64 sse_decode_i_64(SseDeserializer deserializer);
@protected
List<BridgeAudioDevice> sse_decode_list_bridge_audio_device(
SseDeserializer deserializer,
);
@protected
List<BridgeBookmark> sse_decode_list_bridge_bookmark(
SseDeserializer deserializer,
@@ -299,12 +359,18 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
@protected
Uint8List sse_decode_list_prim_u_8_strict(SseDeserializer deserializer);
@protected
String? sse_decode_opt_String(SseDeserializer deserializer);
@protected
BridgeVoiceJoinErrorCode?
sse_decode_opt_box_autoadd_bridge_voice_join_error_code(
SseDeserializer deserializer,
);
@protected
int? sse_decode_opt_box_autoadd_i_32(SseDeserializer deserializer);
@protected
BigInt? sse_decode_opt_box_autoadd_u_64(SseDeserializer deserializer);
@@ -313,16 +379,6 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
SseDeserializer deserializer,
);
@protected
(String, String) sse_decode_record_string_string(
SseDeserializer deserializer,
);
@protected
(String, String, String) sse_decode_record_string_string_string(
SseDeserializer deserializer,
);
@protected
int sse_decode_u_32(SseDeserializer deserializer);
@@ -365,12 +421,21 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
SseSerializer serializer,
);
@protected
void sse_encode_box_autoadd_bridge_message_target(
BridgeMessageTarget self,
SseSerializer serializer,
);
@protected
void sse_encode_box_autoadd_bridge_voice_join_error_code(
BridgeVoiceJoinErrorCode self,
SseSerializer serializer,
);
@protected
void sse_encode_box_autoadd_i_32(int self, SseSerializer serializer);
@protected
void sse_encode_box_autoadd_u_64(BigInt self, SseSerializer serializer);
@@ -380,6 +445,18 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
SseSerializer serializer,
);
@protected
void sse_encode_bridge_audio_device(
BridgeAudioDevice self,
SseSerializer serializer,
);
@protected
void sse_encode_bridge_audio_device_list(
BridgeAudioDeviceList self,
SseSerializer serializer,
);
@protected
void sse_encode_bridge_audio_processing_config(
BridgeAudioProcessingConfig self,
@@ -434,12 +511,30 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
SseSerializer serializer,
);
@protected
void sse_encode_bridge_message_target(
BridgeMessageTarget self,
SseSerializer serializer,
);
@protected
void sse_encode_bridge_network_state(
BridgeNetworkState self,
SseSerializer serializer,
);
@protected
void sse_encode_bridge_ptt_binding(
BridgePttBinding self,
SseSerializer serializer,
);
@protected
void sse_encode_bridge_ptt_descriptor(
BridgePttDescriptor self,
SseSerializer serializer,
);
@protected
void sse_encode_bridge_ptt_input_class(
BridgePttInputClass self,
@@ -485,6 +580,12 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
@protected
void sse_encode_i_64(PlatformInt64 self, SseSerializer serializer);
@protected
void sse_encode_list_bridge_audio_device(
List<BridgeAudioDevice> self,
SseSerializer serializer,
);
@protected
void sse_encode_list_bridge_bookmark(
List<BridgeBookmark> self,
@@ -509,12 +610,18 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
SseSerializer serializer,
);
@protected
void sse_encode_opt_String(String? self, SseSerializer serializer);
@protected
void sse_encode_opt_box_autoadd_bridge_voice_join_error_code(
BridgeVoiceJoinErrorCode? self,
SseSerializer serializer,
);
@protected
void sse_encode_opt_box_autoadd_i_32(int? self, SseSerializer serializer);
@protected
void sse_encode_opt_box_autoadd_u_64(BigInt? self, SseSerializer serializer);
@@ -524,18 +631,6 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
SseSerializer serializer,
);
@protected
void sse_encode_record_string_string(
(String, String) self,
SseSerializer serializer,
);
@protected
void sse_encode_record_string_string_string(
(String, String, String) self,
SseSerializer serializer,
);
@protected
void sse_encode_u_32(int self, SseSerializer serializer);
@@ -0,0 +1,128 @@
import 'package:flutter/material.dart';
import '../src/rust/api.dart' as rust;
/// Loads available input/output audio devices.
typedef AudioDeviceListLoader = Future<rust.BridgeAudioDeviceList> Function();
/// Persists a selected audio device name.
typedef AudioDeviceSetter = Future<void> Function({String? name});
/// Which desktop audio device group this tile manages.
enum AudioDeviceKind {
/// Capture device.
input,
/// Playback device.
output,
}
/// Desktop audio device selector tile.
class AudioDeviceListTile extends StatefulWidget {
/// Construct an audio device list tile.
const AudioDeviceListTile({
super.key,
required this.label,
required this.kind,
AudioDeviceListLoader? loadDevices,
AudioDeviceSetter? setInputDevice,
AudioDeviceSetter? setOutputDevice,
}) : loadDevices = loadDevices ?? rust.listAudioDevices,
setInputDevice = setInputDevice ?? rust.setInputDevice,
setOutputDevice = setOutputDevice ?? rust.setOutputDevice;
/// Tile title.
final String label;
/// Device group managed by this tile.
final AudioDeviceKind kind;
/// Loads available devices.
final AudioDeviceListLoader loadDevices;
/// Selects an input device.
final AudioDeviceSetter setInputDevice;
/// Selects an output device.
final AudioDeviceSetter setOutputDevice;
@override
State<AudioDeviceListTile> createState() => _AudioDeviceListTileState();
}
class _AudioDeviceListTileState extends State<AudioDeviceListTile> {
List<rust.BridgeAudioDevice> _devices = [];
bool _loaded = false;
@override
void initState() {
super.initState();
_loadDevices();
}
Future<void> _loadDevices() async {
final list = await widget.loadDevices();
if (!mounted) return;
setState(() {
_devices = switch (widget.kind) {
AudioDeviceKind.input => list.inputDevices,
AudioDeviceKind.output => list.outputDevices,
};
_loaded = true;
});
}
Future<void> _selectDevice(rust.BridgeAudioDevice device) async {
switch (widget.kind) {
case AudioDeviceKind.input:
await widget.setInputDevice(name: device.name);
case AudioDeviceKind.output:
await widget.setOutputDevice(name: device.name);
}
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('${widget.label} set to ${device.name}'),
duration: const Duration(seconds: 2),
),
);
}
@override
Widget build(BuildContext context) {
if (!_loaded) {
return ListTile(
title: Text(widget.label),
subtitle: const Text('Loading...'),
trailing: const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
),
);
}
if (_devices.isEmpty) {
return ListTile(
title: Text(widget.label),
subtitle: const Text('System default'),
leading: const Icon(Icons.check_circle_outline, size: 18),
);
}
return ExpansionTile(
title: Text(widget.label),
subtitle: Text('${_devices.length} available'),
leading: const Icon(Icons.headphones, size: 18),
children: [
for (final device in _devices)
ListTile(
dense: true,
title: Text(device.name, style: const TextStyle(fontSize: 13)),
trailing: device.isDefault
? const Icon(Icons.check, size: 16, color: Colors.green)
: null,
onTap: device.isDefault ? null : () => _selectDevice(device),
),
],
);
}
}
@@ -0,0 +1,567 @@
import 'dart:async' show StreamSubscription, unawaited;
import 'dart:io' show Platform;
import 'package:audio_session/audio_session.dart';
import 'package:flutter/foundation.dart' show kIsWeb;
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:haptic_kit/haptic_kit.dart';
import '../l10n/generated/app_localizations.dart';
import '../services/android_audio_output_devices.dart';
/// Tile that displays the active audio output route and opens the
/// native picker on tap.
class AudioOutputTile extends StatefulWidget {
/// Construct an audio output tile.
const AudioOutputTile({super.key});
@override
State<AudioOutputTile> createState() => AudioOutputTileState();
}
/// State for [AudioOutputTile].
class AudioOutputTileState extends State<AudioOutputTile> {
static const _androidOutputChannel = MethodChannel('app.audio_output');
static const _androidOutputEvents = EventChannel('app.audio_output/events');
AVAudioSessionPortDescription? _activeOutput;
StreamSubscription<AVAudioSessionRouteChange>? _routeSub;
StreamSubscription<dynamic>? _androidOutputSub;
List<AndroidAudioOutputDevice> _androidDevices = const [];
bool _androidLoading = false;
@override
void initState() {
super.initState();
if (!kIsWeb && Platform.isAndroid) {
_refreshAndroidDevices();
_androidOutputSub = _androidOutputEvents.receiveBroadcastStream().listen((
_,
) {
if (!mounted) return;
_refreshAndroidDevices();
});
return;
}
_refresh();
_routeSub = AVAudioSession().routeChangeStream.listen((_) {
if (!mounted) return;
_refresh();
});
}
@override
void dispose() {
_routeSub?.cancel();
_routeSub = null;
_androidOutputSub?.cancel();
_androidOutputSub = null;
super.dispose();
}
Future<void> _refreshAndroidDevices() async {
if (_androidLoading) return;
setState(() => _androidLoading = true);
try {
final raw =
await _androidOutputChannel.invokeListMethod<dynamic>(
'getOutputDevices',
) ??
const [];
final devices = parseAndroidAudioOutputDevices(raw);
if (!mounted) return;
setState(() {
_androidDevices = devices;
_androidLoading = false;
});
} catch (_) {
if (!mounted) return;
setState(() => _androidLoading = false);
}
}
Future<void> _refresh() async {
try {
final route = await AVAudioSession().currentRoute;
if (!mounted) return;
setState(() {
_activeOutput = route.outputs.firstOrNull;
});
} catch (_) {
// AVAudioSession may transiently throw before the session is active.
}
}
static String portLabel(
AVAudioSessionPort? type,
String fallback,
AppL10n l10n,
) {
switch (type) {
case AVAudioSessionPort.builtInSpeaker:
return l10n.audioRouteSpeaker;
case AVAudioSessionPort.builtInReceiver:
return l10n.audioRouteReceiver;
case AVAudioSessionPort.bluetoothHfp:
case AVAudioSessionPort.bluetoothA2dp:
case AVAudioSessionPort.bluetoothLe:
return fallback.isEmpty ? l10n.audioRouteBluetooth : fallback;
case AVAudioSessionPort.headphones:
case AVAudioSessionPort.headsetMic:
return fallback.isEmpty ? l10n.audioRouteWiredHeadset : fallback;
case AVAudioSessionPort.carAudio:
return l10n.audioRouteCarAudio;
case AVAudioSessionPort.airPlay:
return l10n.audioRouteAirplay;
case AVAudioSessionPort.builtInMic:
return fallback.isEmpty ? l10n.audioRouteReceiver : fallback;
case null:
default:
return fallback.isEmpty ? l10n.audioRouteUnknown : fallback;
}
}
static IconData portIcon(AVAudioSessionPort? type) {
switch (type) {
case AVAudioSessionPort.builtInSpeaker:
return Icons.volume_up;
case AVAudioSessionPort.builtInReceiver:
return Icons.phone_in_talk;
case AVAudioSessionPort.bluetoothHfp:
case AVAudioSessionPort.bluetoothA2dp:
case AVAudioSessionPort.bluetoothLe:
return Icons.bluetooth_audio;
case AVAudioSessionPort.headphones:
case AVAudioSessionPort.headsetMic:
return Icons.headset;
case AVAudioSessionPort.carAudio:
return Icons.directions_car;
case AVAudioSessionPort.airPlay:
return Icons.airplay;
default:
return Icons.speaker;
}
}
Future<void> _openPicker() async {
if (!kIsWeb && Platform.isAndroid) {
final selectedDeviceId = await showModalBottomSheet<String>(
context: context,
showDragHandle: true,
isScrollControlled: true,
builder: (ctx) => _AndroidAudioOutputPickerSheet(
devices: _androidDevices,
loading: _androidLoading,
onRefresh: _refreshAndroidDevices,
),
);
if (selectedDeviceId == null) return;
try {
if (selectedDeviceId == 'auto') {
await _androidOutputChannel.invokeMethod<void>(
'clearCommunicationDevice',
);
} else {
final changed = await _androidOutputChannel.invokeMethod<bool>(
'setCommunicationDevice',
{'deviceId': selectedDeviceId},
);
if (changed != true && mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(AppL10n.of(context).audioRouteCannotSelect),
),
);
}
}
await _refreshAndroidDevices();
} catch (_) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(AppL10n.of(context).audioRouteChangeFailed)),
);
}
return;
}
await showModalBottomSheet<void>(
context: context,
showDragHandle: true,
isScrollControlled: true,
builder: (ctx) => const _AudioOutputPickerSheet(),
);
await _refresh();
}
@override
Widget build(BuildContext context) {
final l10n = AppL10n.of(context);
if (!kIsWeb && Platform.isAndroid) {
final selected = selectedAndroidAudioOutputDevice(_androidDevices);
final label = selected == null
? l10n.audioRouteSystemDefault
: _androidDeviceLabel(selected.type, selected.name, l10n);
return _AudioRouteRow(
icon: _androidDeviceIcon(selected?.type),
label: _androidLoading ? '${l10n.audioRouteUnknown}' : label,
onTap: _openPicker,
);
}
final port = _activeOutput;
final label = portLabel(port?.portType, port?.portName ?? '', l10n);
return _AudioRouteRow(
icon: portIcon(port?.portType),
label: label,
onTap: _openPicker,
);
}
static String _androidDeviceLabel(
String type,
String fallback,
AppL10n l10n,
) => switch (type) {
'speaker' => l10n.audioRouteSpeaker,
'earpiece' => l10n.audioRouteEarpiece,
'wiredHeadset' || 'wiredHeadphones' => l10n.audioRouteWiredHeadset,
'bluetoothA2dp' ||
'bluetoothSco' ||
'bluetoothLe' => l10n.audioRouteBluetooth,
'usbHeadset' => fallback.isEmpty ? l10n.audioRouteUsbHeadset : fallback,
'hdmi' => l10n.audioRouteCarAudio,
_ => fallback.isEmpty ? l10n.audioRouteOtherDevice : fallback,
};
static IconData _androidDeviceIcon(String? type) => switch (type) {
'speaker' => Icons.volume_up,
'earpiece' => Icons.phone_in_talk,
'wiredHeadset' || 'wiredHeadphones' || 'usbHeadset' => Icons.headset,
'bluetoothA2dp' || 'bluetoothSco' || 'bluetoothLe' => Icons.bluetooth_audio,
'hdmi' => Icons.tv,
_ => Icons.speaker,
};
}
class _AudioRouteRow extends StatelessWidget {
const _AudioRouteRow({
required this.icon,
required this.label,
required this.onTap,
});
final IconData icon;
final String label;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final l10n = AppL10n.of(context);
return InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(12),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 4),
child: Row(
children: [
Icon(icon, color: theme.colorScheme.primary),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
l10n.audioOutputLabel,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
Text(
label,
style: theme.textTheme.bodyLarge?.copyWith(
fontWeight: FontWeight.w500,
),
),
],
),
),
Icon(
Icons.chevron_right,
color: theme.colorScheme.onSurfaceVariant,
),
],
),
),
);
}
}
class _AndroidAudioOutputPickerSheet extends StatelessWidget {
const _AndroidAudioOutputPickerSheet({
required this.devices,
required this.loading,
required this.onRefresh,
});
final List<AndroidAudioOutputDevice> devices;
final bool loading;
final Future<void> Function() onRefresh;
@override
Widget build(BuildContext context) {
final l10n = AppL10n.of(context);
final theme = Theme.of(context);
return SafeArea(
child: Padding(
padding: const EdgeInsets.fromLTRB(20, 8, 20, 24),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(l10n.audioOutputLabel, style: theme.textTheme.titleLarge),
const SizedBox(height: 16),
_PickerRow(
icon: Icons.speaker,
label: l10n.audioRouteSystemDefault,
selected: !devices.any((d) => d.isSelected),
onTap: () => Navigator.of(context).pop('auto'),
),
if (loading)
const Padding(
padding: EdgeInsets.symmetric(vertical: 24),
child: Center(child: CircularProgressIndicator()),
)
else if (devices.isEmpty)
TextButton.icon(
onPressed: onRefresh,
icon: const Icon(Icons.refresh),
label: Text(l10n.audioRouteRefreshDevices),
)
else
for (final device in devices)
_PickerRow(
icon: AudioOutputTileState._androidDeviceIcon(device.type),
label: AudioOutputTileState._androidDeviceLabel(
device.type,
device.name,
l10n,
),
selected: device.isSelected,
onTap: device.isAvailableForCommunication
? () => Navigator.of(context).pop(device.id)
: null,
),
],
),
),
);
}
}
class _AudioOutputPickerSheet extends StatefulWidget {
const _AudioOutputPickerSheet();
@override
State<_AudioOutputPickerSheet> createState() =>
_AudioOutputPickerSheetState();
}
class _AudioOutputPickerSheetState extends State<_AudioOutputPickerSheet> {
Set<AVAudioSessionPortDescription> _availableInputs = const {};
AVAudioSessionRouteDescription? _route;
StreamSubscription<AVAudioSessionRouteChange>? _routeSub;
bool _loading = true;
@override
void initState() {
super.initState();
_refresh();
_routeSub = AVAudioSession().routeChangeStream.listen((_) {
if (!mounted) return;
_refresh();
});
}
@override
void dispose() {
_routeSub?.cancel();
_routeSub = null;
super.dispose();
}
Future<void> _refresh() async {
try {
final session = AVAudioSession();
final inputs = await session.availableInputs;
final route = await session.currentRoute;
if (!mounted) return;
setState(() {
_availableInputs = inputs;
_route = route;
_loading = false;
});
} catch (_) {
if (!mounted) return;
setState(() => _loading = false);
}
}
Future<void> _selectSpeaker() async {
try {
await AVAudioSession().overrideOutputAudioPort(
AVAudioSessionPortOverride.speaker,
);
unawaited(Haptics.selection().catchError((_) {}));
debugPrint('chanora: audio output -> speakerphone (override applied)');
} catch (e, st) {
debugPrint('chanora: _selectSpeaker FAILED: $e\n$st');
}
if (!mounted) return;
Navigator.of(context).pop();
}
Future<void> _selectReceiver() async {
try {
await AVAudioSession().overrideOutputAudioPort(
AVAudioSessionPortOverride.none,
);
unawaited(Haptics.selection().catchError((_) {}));
debugPrint('chanora: audio output -> receiver (override cleared)');
} catch (e, st) {
debugPrint('chanora: _selectReceiver FAILED: $e\n$st');
}
if (!mounted) return;
Navigator.of(context).pop();
}
Future<void> _selectInput(AVAudioSessionPortDescription port) async {
try {
await AVAudioSession().overrideOutputAudioPort(
AVAudioSessionPortOverride.none,
);
await AVAudioSession().setPreferredInput(port);
unawaited(Haptics.selection().catchError((_) {}));
debugPrint('chanora: audio output -> ${port.portType}');
} catch (e, st) {
debugPrint('chanora: _selectInput FAILED: $e\n$st');
}
if (!mounted) return;
Navigator.of(context).pop();
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final l10n = AppL10n.of(context);
if (_loading) {
return const SafeArea(
child: Padding(
padding: EdgeInsets.all(40),
child: Center(child: CircularProgressIndicator()),
),
);
}
final currentOutputType = _route?.outputs.firstOrNull?.portType;
final currentInputUid = _route?.inputs.firstOrNull?.uid;
final isSpeaker = currentOutputType == AVAudioSessionPort.builtInSpeaker;
final isReceiver = currentOutputType == AVAudioSessionPort.builtInReceiver;
final externalInputs = _availableInputs
.where((p) => p.portType != AVAudioSessionPort.builtInMic)
.toList();
return SafeArea(
child: SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(20, 8, 20, 24),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(l10n.audioOutputLabel, style: theme.textTheme.titleLarge),
const SizedBox(height: 16),
_PickerRow(
icon: Icons.volume_up,
label: l10n.audioRouteSpeaker,
selected: isSpeaker,
onTap: _selectSpeaker,
),
_PickerRow(
icon: Icons.phone_in_talk,
label: l10n.audioRouteReceiver,
selected: isReceiver,
onTap: _selectReceiver,
),
for (final port in externalInputs)
_PickerRow(
icon: AudioOutputTileState.portIcon(port.portType),
label: AudioOutputTileState.portLabel(
port.portType,
port.portName,
l10n,
),
selected: !isSpeaker && port.uid == currentInputUid,
onTap: () => _selectInput(port),
),
],
),
),
);
}
}
class _PickerRow extends StatelessWidget {
const _PickerRow({
required this.icon,
required this.label,
required this.selected,
required this.onTap,
});
final IconData icon;
final String label;
final bool selected;
final VoidCallback? onTap;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final enabled = onTap != null;
final color = selected
? theme.colorScheme.primary
: enabled
? theme.colorScheme.onSurface
: theme.colorScheme.onSurfaceVariant.withAlpha(130);
return Semantics(
button: true,
selected: selected,
enabled: enabled,
label: label,
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(8),
child: ExcludeSemantics(
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 4),
child: Row(
children: [
Icon(icon, color: color),
const SizedBox(width: 16),
Expanded(
child: Text(
label,
style: theme.textTheme.bodyLarge?.copyWith(color: color),
),
),
if (selected)
Icon(Icons.check, color: theme.colorScheme.primary),
],
),
),
),
),
);
}
}
@@ -0,0 +1,147 @@
import 'dart:io' show Platform;
import '../src/rust/api.dart' as rust;
/// Fallback audio-processing config used before the bridge can report one.
const defaultAudioProcessingConfig = rust.BridgeAudioProcessingConfig(
route: rust.BridgeAudioRoute.unknown,
iosMode: rust.BridgeIosVoiceProcessingMode.platformVoiceProcessing,
processingBackend: rust.BridgeAudioBackend.platformVoiceProcessing,
vadBackend: rust.BridgeVadBackend.sileroOnnx,
aec: rust.BridgeEffectOwner.platform,
ns: rust.BridgeEffectOwner.platform,
agc: rust.BridgeEffectOwner.platform,
hpfEnabled: true,
limiterEnabled: true,
vadHangoverMs: 500,
vadPreRollMs: 160,
vadMinTxMs: 200,
debugWavDumpEnabled: false,
);
/// Mutable UI state for audio-processing controls.
class AudioProcessingConfigState {
/// Build control state from a bridge config.
AudioProcessingConfigState.fromConfig(rust.BridgeAudioProcessingConfig config)
: nsEnabled = config.ns != rust.BridgeEffectOwner.off,
aecEnabled = config.aec != rust.BridgeEffectOwner.off,
agcEnabled = config.agc != rust.BridgeEffectOwner.off,
hpfEnabled = config.hpfEnabled,
limiterEnabled = config.limiterEnabled,
debugWavDump = config.debugWavDumpEnabled,
preferHardware = _usesPlatformEffects(config),
vadBackend = normalizedVadBackend(config.vadBackend),
iosMode = config.iosMode;
/// Noise suppression toggle.
bool nsEnabled;
/// Echo cancellation toggle.
bool aecEnabled;
/// Automatic gain-control toggle.
bool agcEnabled;
/// High-pass filter toggle.
bool hpfEnabled;
/// Limiter toggle.
bool limiterEnabled;
/// Debug WAV dump toggle.
bool debugWavDump;
/// Android hardware effects preference.
bool preferHardware;
/// Selected VAD backend.
rust.BridgeVadBackend vadBackend;
/// Selected iOS processing mode.
rust.BridgeIosVoiceProcessingMode iosMode;
/// Build the bridge config represented by this UI state.
rust.BridgeAudioProcessingConfig buildConfig({
required rust.BridgeAudioProcessingConfig base,
bool? isAndroid,
}) {
final android = isAndroid ?? Platform.isAndroid;
final vad = normalizedVadBackend(vadBackend);
if (android) {
final owner = preferHardware
? rust.BridgeEffectOwner.platform
: rust.BridgeEffectOwner.webrtcApm;
return rust.BridgeAudioProcessingConfig(
route: base.route,
iosMode: iosMode,
processingBackend: preferHardware
? rust.BridgeAudioBackend.platformVoiceProcessing
: rust.BridgeAudioBackend.webrtcApm,
vadBackend: vad,
aec: aecEnabled ? owner : rust.BridgeEffectOwner.off,
ns: nsEnabled ? owner : rust.BridgeEffectOwner.off,
agc: agcEnabled ? owner : rust.BridgeEffectOwner.off,
hpfEnabled: hpfEnabled,
limiterEnabled: limiterEnabled,
vadHangoverMs: base.vadHangoverMs,
vadPreRollMs: base.vadPreRollMs,
vadMinTxMs: base.vadMinTxMs,
debugWavDumpEnabled: debugWavDump,
);
}
final isSonora =
iosMode == rust.BridgeIosVoiceProcessingMode.sonoraExperimental;
final aecOwner = isSonora
? (aecEnabled
? rust.BridgeEffectOwner.webrtcApm
: rust.BridgeEffectOwner.off)
: rust.BridgeEffectOwner.platform;
final nsOwner = isSonora
? (nsEnabled
? rust.BridgeEffectOwner.webrtcApm
: rust.BridgeEffectOwner.off)
: (nsEnabled
? rust.BridgeEffectOwner.platform
: rust.BridgeEffectOwner.off);
final agcOwner = isSonora
? (agcEnabled
? rust.BridgeEffectOwner.webrtcApm
: rust.BridgeEffectOwner.off)
: (agcEnabled
? rust.BridgeEffectOwner.platform
: rust.BridgeEffectOwner.off);
return rust.BridgeAudioProcessingConfig(
route: base.route,
iosMode: iosMode,
processingBackend: isSonora
? rust.BridgeAudioBackend.webrtcApm
: rust.BridgeAudioBackend.platformVoiceProcessing,
vadBackend: vad,
aec: aecOwner,
ns: nsOwner,
agc: agcOwner,
hpfEnabled: hpfEnabled,
limiterEnabled: limiterEnabled,
vadHangoverMs: base.vadHangoverMs,
vadPreRollMs: base.vadPreRollMs,
vadMinTxMs: base.vadMinTxMs,
debugWavDumpEnabled: debugWavDump,
);
}
}
/// Never leave the UI on the hidden disabled backend.
rust.BridgeVadBackend normalizedVadBackend(rust.BridgeVadBackend backend) {
return backend == rust.BridgeVadBackend.disabled
? rust.BridgeVadBackend.webrtcVad
: backend;
}
bool _usesPlatformEffects(rust.BridgeAudioProcessingConfig config) {
return config.aec == rust.BridgeEffectOwner.platform ||
config.ns == rust.BridgeEffectOwner.platform ||
config.agc == rust.BridgeEffectOwner.platform;
}
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:url_launcher/url_launcher.dart';
import '../services/link_trust_service.dart';
import '../services/ts3_server_link.dart';
final _tagRe = RegExp(
r'\[(\/?(?:b|i|u|s'
@@ -13,12 +14,14 @@ final _tagRe = RegExp(
r'))\]',
caseSensitive: false,
);
final _colorRe = RegExp(r'color=([#\w]+)');
final _sizeRe = RegExp(r'size=(\d+)');
final _urlRe = RegExp(r'url=(.+)');
final _urlRe = RegExp(r'url=(.+)', caseSensitive: false);
final _imgRe = RegExp(r'img=(.+)');
final _urlAutoRe = RegExp(r'(?:\[url\])?(https?://[^\s\[\]]+)(?:\[/url\])?', caseSensitive: false);
final _urlAutoRe = RegExp(
r'(?:\[url\])?((?:https?|ts3server)://[^\s\[\]]+)(?:\[/url\])?',
caseSensitive: false,
);
final _closeUrlRe = RegExp(r'\[/url\]', caseSensitive: false);
const _linkStyle = TextStyle(color: Colors.blue);
int? _findCloseUrl(String src, int from) {
final m = _closeUrlRe.matchAsPrefix(src, from);
@@ -42,10 +45,16 @@ Color? _parseColor(String hex) {
}
class BbCodeText extends StatelessWidget {
const BbCodeText(this.text, {super.key, required this.linkTrust});
const BbCodeText(
this.text, {
super.key,
required this.linkTrust,
this.onTs3ServerLink,
});
final String text;
final LinkTrustService linkTrust;
final Ts3ServerLinkHandler? onTs3ServerLink;
@override
Widget build(BuildContext context) {
@@ -63,20 +72,17 @@ class BbCodeText extends StatelessWidget {
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,
),
parts.add(
WidgetSpan(
alignment: PlaceholderAlignment.middle,
child: _LinkTap(
url: url,
linkTrust: linkTrust,
onTs3ServerLink: onTs3ServerLink,
child: Text(url, style: _linkStyle),
),
),
));
);
last = m.end;
}
if (last < src.length) {
@@ -118,19 +124,21 @@ class BbCodeText extends StatelessWidget {
}
}
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,
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();
@@ -150,7 +158,8 @@ class BbCodeText extends StatelessWidget {
}
flush(buf);
final raw = m.group(1)!.toLowerCase();
final rawTag = m.group(1)!;
final raw = rawTag.toLowerCase();
i = m.end;
if (raw.startsWith('/')) {
@@ -184,7 +193,7 @@ class BbCodeText extends StatelessWidget {
if (raw.startsWith('color=') || raw.startsWith('size=')) {
tags.add(raw);
} else if (raw.startsWith('url=')) {
final url = _urlRe.firstMatch(raw)?.group(1) ?? '';
final url = _urlRe.firstMatch(rawTag)?.group(1) ?? '';
final closeIdx = _findCloseUrl(src, i);
String inner;
if (closeIdx != null) {
@@ -193,53 +202,49 @@ class BbCodeText extends StatelessWidget {
} 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,
),
spans.add(
WidgetSpan(
alignment: PlaceholderAlignment.middle,
child: _LinkTap(
url: url.isNotEmpty ? url : inner,
linkTrust: linkTrust,
onTs3ServerLink: onTs3ServerLink,
child: Text(inner, style: _linkStyle),
),
),
));
);
} 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(),
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,
),
spans.add(
WidgetSpan(
alignment: PlaceholderAlignment.middle,
child: _LinkTap(
url: url,
linkTrust: linkTrust,
onTs3ServerLink: onTs3ServerLink,
child: Text(url, style: _linkStyle),
),
),
));
);
}
}
break;
@@ -258,11 +263,13 @@ class _LinkTap extends StatefulWidget {
required this.url,
required this.child,
required this.linkTrust,
this.onTs3ServerLink,
});
final String url;
final Widget child;
final LinkTrustService linkTrust;
final Ts3ServerLinkHandler? onTs3ServerLink;
@override
State<_LinkTap> createState() => _LinkTapState();
@@ -284,6 +291,12 @@ class _LinkTapState extends State<_LinkTap> {
void _onChanged() => mounted ? setState(() {}) : null;
Future<void> _open() async {
final ts3Link = parseTs3ServerLink(widget.url);
if (ts3Link != null) {
await widget.onTs3ServerLink?.call(ts3Link);
return;
}
final uri = Uri.tryParse(widget.url);
if (uri == null) return;
final host = uri.host;
@@ -305,9 +318,6 @@ class _LinkTapState extends State<_LinkTap> {
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: _open,
child: widget.child,
);
return GestureDetector(onTap: _open, child: widget.child);
}
}
+321 -236
View File
@@ -280,13 +280,20 @@ class ChatPage extends StatefulWidget {
}
class _ChatPageState extends State<ChatPage> {
rust.BridgeMessageTarget? _detailTarget;
late rust.BridgeMessageTarget _selectedTarget;
String _selectedClientName = '';
final Set<BigInt> _closedPrivateChats = {};
@override
void initState() {
super.initState();
_detailTarget = widget.initialTarget;
_selectedTarget =
widget.initialTarget ??
resolveInitialChatTarget(
messages: widget.messages,
currentVoiceChannelId: _currentChannelId,
) ??
const rust.BridgeMessageTarget.server();
_selectedClientName = widget.initialClientName;
}
@@ -298,31 +305,102 @@ class _ChatPageState extends State<ChatPage> {
Widget build(BuildContext context) {
final currentChannelId = _currentChannelId;
final channelName = snapshotChannelName(widget.snapshot, currentChannelId);
if (_detailTarget != null) {
return _ChatDetailView(
target: _detailTarget!,
clientName: _selectedClientName,
snapshot: widget.snapshot,
messages: widget.messages,
currentChannelId: currentChannelId,
channelName: channelName,
onBack: () => setState(() => _detailTarget = null),
onTs3ServerLink: widget.onTs3ServerLink,
return Scaffold(
appBar: AppBar(
title: Text('Chat — ${widget.snapshot.serverName}'),
actions: [
IconButton(
tooltip: 'Close chat',
icon: const Icon(Icons.close),
onPressed: _selectedPrivateClientId == null
? null
: _closeSelectedPrivateChat,
),
],
),
body: Row(
children: [
_ChatSidebar(
selectedTarget: _selectedTarget,
privateChats: _privateChats,
onSelect: _selectTarget,
onNewPrivateChat: () => _pickClient((id, name) {
_closedPrivateChats.remove(id);
_selectTarget(rust.BridgeMessageTarget.client(id), name: name);
}),
),
const VerticalDivider(width: 1),
Expanded(
child: _ChatDetailView(
target: _selectedTarget,
clientName: _selectedClientName,
snapshot: widget.snapshot,
messages: widget.messages,
currentChannelId: currentChannelId,
channelName: channelName,
onTs3ServerLink: widget.onTs3ServerLink,
),
),
],
),
);
}
BigInt? get _selectedPrivateClientId {
final target = _selectedTarget;
return target is rust.BridgeMessageTarget_Client ? target.field0 : null;
}
List<_PrivateChatItem> get _privateChats {
final chats = <BigInt, _PrivateChatItem>{};
for (final message in widget.messages) {
final target = message.target;
if (target is! rust.BridgeMessageTarget_Client) continue;
final id = target.field0;
if (_closedPrivateChats.contains(id)) continue;
final existing = chats[id];
final name = existing?.name.isNotEmpty == true
? existing!.name
: _privateChatName(id, message.senderName);
chats[id] = _PrivateChatItem(id: id, name: name);
}
final selectedId = _selectedPrivateClientId;
if (selectedId != null && !_closedPrivateChats.contains(selectedId)) {
chats.putIfAbsent(
selectedId,
() => _PrivateChatItem(
id: selectedId,
name: _selectedClientName.isNotEmpty ? _selectedClientName : 'Direct',
),
);
}
return _ChatHub(
snapshot: widget.snapshot,
messages: widget.messages,
currentChannelId: currentChannelId,
channelName: channelName,
onOpen: (t, {String name = ''}) {
setState(() {
_detailTarget = t;
_selectedClientName = name;
});
},
onPickClient: (fn) => _pickClient(fn),
);
return chats.values.toList()..sort((a, b) => a.name.compareTo(b.name));
}
String _privateChatName(BigInt id, String fallback) {
for (final client in widget.snapshot.clients) {
if (client.id == id && client.name.isNotEmpty) return client.name;
}
return fallback.isNotEmpty && fallback != 'You' ? fallback : 'Direct';
}
void _selectTarget(rust.BridgeMessageTarget target, {String name = ''}) {
setState(() {
_selectedTarget = target;
_selectedClientName = name;
});
}
void _closeSelectedPrivateChat() {
final id = _selectedPrivateClientId;
if (id == null) return;
setState(() {
_closedPrivateChats.add(id);
_selectedTarget = _currentChannelId != null
? const rust.BridgeMessageTarget.channel()
: const rust.BridgeMessageTarget.server();
_selectedClientName = '';
});
}
void _pickClient(void Function(BigInt id, String name) cb) {
@@ -342,6 +420,134 @@ class _ChatPageState extends State<ChatPage> {
}
}
class _PrivateChatItem {
const _PrivateChatItem({required this.id, required this.name});
final BigInt id;
final String name;
}
class _ChatSidebar extends StatelessWidget {
const _ChatSidebar({
required this.selectedTarget,
required this.privateChats,
required this.onSelect,
required this.onNewPrivateChat,
});
final rust.BridgeMessageTarget selectedTarget;
final List<_PrivateChatItem> privateChats;
final void Function(rust.BridgeMessageTarget target, {String name}) onSelect;
final VoidCallback onNewPrivateChat;
@override
Widget build(BuildContext context) {
return SizedBox(
width: 148,
child: Column(
children: [
_ChatSidebarItem(
icon: Icons.dns_outlined,
label: 'Server',
selected: selectedTarget is rust.BridgeMessageTarget_Server,
onTap: () => onSelect(const rust.BridgeMessageTarget.server()),
),
_ChatSidebarItem(
icon: Icons.tag,
label: 'Channel',
selected: selectedTarget is rust.BridgeMessageTarget_Channel,
onTap: () => onSelect(const rust.BridgeMessageTarget.channel()),
),
const Divider(height: 1),
Expanded(
child: ListView.builder(
padding: EdgeInsets.zero,
itemCount: privateChats.length,
itemBuilder: (context, index) {
final chat = privateChats[index];
final selected = switch (selectedTarget) {
rust.BridgeMessageTarget_Client(:final field0) =>
field0 == chat.id,
_ => false,
};
return _ChatSidebarItem(
icon: Icons.person_outline,
label: chat.name.isNotEmpty ? chat.name : 'Direct',
selected: selected,
onTap: () => onSelect(
rust.BridgeMessageTarget.client(chat.id),
name: chat.name,
),
);
},
),
),
const Divider(height: 1),
Padding(
padding: const EdgeInsets.all(8),
child: IconButton.filledTonal(
tooltip: 'New private chat',
icon: const Icon(Icons.add),
onPressed: onNewPrivateChat,
),
),
],
),
);
}
}
class _ChatSidebarItem extends StatelessWidget {
const _ChatSidebarItem({
required this.icon,
required this.label,
required this.selected,
required this.onTap,
});
final IconData icon;
final String label;
final bool selected;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final bg = selected
? theme.colorScheme.primaryContainer
: Colors.transparent;
final fg = selected
? theme.colorScheme.onPrimaryContainer
: theme.colorScheme.onSurface;
return Material(
color: bg,
child: InkWell(
onTap: onTap,
child: SizedBox(
height: 44,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 10),
child: Row(
children: [
Icon(icon, size: 18, color: fg),
const SizedBox(width: 8),
Expanded(
child: Text(
label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.bodyMedium?.copyWith(color: fg),
),
),
],
),
),
),
),
);
}
}
class _ClientPickerDialog extends StatefulWidget {
const _ClientPickerDialog({
required this.channels,
@@ -461,124 +667,6 @@ class _ChannelGroup extends StatelessWidget {
}
}
class _ChatHub extends StatelessWidget {
const _ChatHub({
required this.snapshot,
required this.messages,
required this.currentChannelId,
required this.channelName,
required this.onOpen,
required this.onPickClient,
});
final rust.BridgeSnapshot snapshot;
final List<ChatEntry> messages;
final BigInt? currentChannelId;
final String channelName;
final void Function(rust.BridgeMessageTarget t, {String name}) onOpen;
final void Function(void Function(BigInt id, String name) cb) onPickClient;
Iterable<ChatEntry> _of(rust.BridgeMessageTarget t) =>
messages.where((m) => m.target == t);
@override
Widget build(BuildContext context) {
final channelMsgs = _of(const rust.BridgeMessageTarget.channel());
final privateMsgs = messages.where((m) => m.isPrivate);
final serverMsgs = _of(const rust.BridgeMessageTarget.server());
return Scaffold(
appBar: AppBar(title: Text('Chat & Activity — ${snapshot.serverName}')),
body: ListView(
padding: const EdgeInsets.all(16),
children: [
_HubCard(
icon: Icons.tag,
title: channelName.isNotEmpty
? '# $channelName'
: 'Current Channel',
subtitle: channelMsgs.isNotEmpty
? '${channelMsgs.length} message${channelMsgs.length == 1 ? '' : 's'}'
: 'No messages yet',
detail: currentChannelId != null
? 'You can send messages here'
: 'Join a channel to send messages',
onTap: () => onOpen(const rust.BridgeMessageTarget.channel()),
),
const SizedBox(height: 12),
_HubCard(
icon: Icons.person_outline,
title: 'Direct Messages',
subtitle: privateMsgs.isNotEmpty
? '${privateMsgs.length} message${privateMsgs.length == 1 ? '' : 's'}'
: 'No private messages',
detail: 'Select a user to start a private chat.',
onTap: () => onPickClient(
(id, name) =>
onOpen(rust.BridgeMessageTarget.client(id), name: name),
),
),
const SizedBox(height: 12),
_HubCard(
icon: Icons.list_alt_outlined,
title: 'Server Activity',
subtitle: serverMsgs.isNotEmpty
? '${serverMsgs.length} message${serverMsgs.length == 1 ? '' : 's'}'
: 'No server activity',
detail: serverMsgs.isNotEmpty
? 'Server messages and events appear here.'
: 'Server-wide messages will appear here.',
onTap: () => onOpen(const rust.BridgeMessageTarget.server()),
),
],
),
);
}
}
class _HubCard extends StatelessWidget {
const _HubCard({
required this.icon,
required this.title,
required this.subtitle,
required this.detail,
required this.onTap,
});
final IconData icon;
final String title;
final String subtitle;
final String detail;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Card(
clipBehavior: Clip.antiAlias,
child: ListTile(
leading: Icon(icon, color: theme.colorScheme.primary),
title: Text(title, style: theme.textTheme.titleSmall),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(height: 4),
Text(subtitle, style: theme.textTheme.bodyMedium),
Text(
detail,
style: theme.textTheme.labelSmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
],
),
trailing: const Icon(Icons.chevron_right),
onTap: onTap,
),
);
}
}
class _ChatDetailView extends StatefulWidget {
const _ChatDetailView({
required this.target,
@@ -587,7 +675,6 @@ class _ChatDetailView extends StatefulWidget {
required this.messages,
required this.currentChannelId,
required this.channelName,
required this.onBack,
this.onTs3ServerLink,
});
@@ -597,7 +684,6 @@ class _ChatDetailView extends StatefulWidget {
final List<ChatEntry> messages;
final BigInt? currentChannelId;
final String channelName;
final VoidCallback onBack;
final Ts3ServerLinkHandler? onTs3ServerLink;
@override
@@ -682,109 +768,108 @@ class _ChatDetailViewState extends State<_ChatDetailView> {
clientName: widget.clientName,
);
return Scaffold(
appBar: AppBar(
leading: IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: () => Navigator.of(context).pop(),
return Column(
children: [
Container(
height: 48,
padding: const EdgeInsets.symmetric(horizontal: 16),
alignment: Alignment.centerLeft,
decoration: BoxDecoration(
border: Border(
bottom: BorderSide(color: theme.colorScheme.outlineVariant),
),
),
child: Text(_title, style: theme.textTheme.titleMedium),
),
title: Text(_title),
actions: [
TextButton(onPressed: widget.onBack, child: const Text('Activity')),
],
),
body: Column(
children: [
Expanded(
child: msgs.isEmpty
? Center(
child: Padding(
padding: const EdgeInsets.all(32),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.forum_outlined,
size: 48,
Expanded(
child: msgs.isEmpty
? Center(
child: Padding(
padding: const EdgeInsets.all(32),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.forum_outlined,
size: 48,
color: theme.colorScheme.onSurfaceVariant,
),
const SizedBox(height: 16),
Text(
chatEmptyTitle(widget.target),
style: theme.textTheme.titleMedium,
textAlign: TextAlign.center,
),
const SizedBox(height: 8),
Text(
chatEmptyBody(
widget.target,
channelName: widget.channelName,
),
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
const SizedBox(height: 16),
Text(
chatEmptyTitle(widget.target),
style: theme.textTheme.titleMedium,
textAlign: TextAlign.center,
),
const SizedBox(height: 8),
Text(
chatEmptyBody(
widget.target,
channelName: widget.channelName,
),
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
textAlign: TextAlign.center,
),
],
),
),
)
: ListView.builder(
controller: _scrollCtl,
padding: const EdgeInsets.symmetric(vertical: 8),
itemCount: msgs.length,
itemBuilder: (_, i) => _MessageBubble(
entry: msgs[i],
onTs3ServerLink: widget.onTs3ServerLink,
textAlign: TextAlign.center,
),
],
),
),
),
if (_blockedReason != null)
Container(
width: double.infinity,
padding: const EdgeInsets.all(12),
color: theme.colorScheme.surfaceContainerHighest,
child: Text(
_blockedReason!,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
)
: ListView.builder(
controller: _scrollCtl,
padding: const EdgeInsets.symmetric(vertical: 8),
itemCount: msgs.length,
itemBuilder: (_, i) => _MessageBubble(
entry: msgs[i],
onTs3ServerLink: widget.onTs3ServerLink,
),
),
textAlign: TextAlign.center,
),
if (_blockedReason != null)
Container(
width: double.infinity,
padding: const EdgeInsets.all(12),
color: theme.colorScheme.surfaceContainerHighest,
child: Text(
_blockedReason!,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
textAlign: TextAlign.center,
),
if (_canSend && _blockedReason == null)
Padding(
padding: const EdgeInsets.all(8),
child: Row(
children: [
Expanded(
child: TextField(
controller: _textCtl,
decoration: InputDecoration(
hintText: placeholder,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(24),
),
contentPadding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 10,
),
),
if (_canSend && _blockedReason == null)
Padding(
padding: const EdgeInsets.all(8),
child: Row(
children: [
Expanded(
child: TextField(
controller: _textCtl,
decoration: InputDecoration(
hintText: placeholder,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(24),
),
contentPadding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 10,
),
textInputAction: TextInputAction.send,
onSubmitted: (_) => _send(),
),
textInputAction: TextInputAction.send,
onSubmitted: (_) => _send(),
),
const SizedBox(width: 8),
IconButton.filled(
icon: const Icon(Icons.send),
onPressed: _send,
tooltip: 'Send',
),
],
),
),
const SizedBox(width: 8),
IconButton.filled(
icon: const Icon(Icons.send),
onPressed: _send,
tooltip: 'Send',
),
],
),
],
),
),
],
);
}
}
@@ -0,0 +1,197 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../l10n/generated/app_localizations.dart';
import '../src/rust/api.dart' as rust;
/// Server connection form.
class ConnectForm extends StatefulWidget {
/// Construct a connect form.
const ConnectForm({
super.key,
required this.hostCtl,
required this.nickCtl,
required this.passwordCtl,
required this.onConnect,
required this.onAddBookmark,
});
/// Server host controller.
final TextEditingController hostCtl;
/// Nickname controller.
final TextEditingController nickCtl;
/// Server password controller.
final TextEditingController passwordCtl;
/// Called when the user submits a connection.
final VoidCallback onConnect;
/// Called when the user saves the current form as a bookmark.
final VoidCallback onAddBookmark;
@override
State<ConnectForm> createState() => _ConnectFormState();
}
class _ConnectFormState extends State<ConnectForm> {
final FocusNode _hostFocus = FocusNode();
final FocusNode _nickFocus = FocusNode();
final FocusNode _passwordFocus = FocusNode();
void _onTapOutside(PointerDownEvent _) {
FocusManager.instance.primaryFocus?.unfocus();
}
@override
void dispose() {
_hostFocus.dispose();
_nickFocus.dispose();
_passwordFocus.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final l10n = AppL10n.of(context);
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
TextField(
controller: widget.hostCtl,
focusNode: _hostFocus,
onTapOutside: _onTapOutside,
keyboardType: TextInputType.url,
textCapitalization: TextCapitalization.none,
textInputAction: TextInputAction.next,
autocorrect: false,
enableSuggestions: false,
inputFormatters: [
FilteringTextInputFormatter.deny(RegExp(r'\s')),
TextInputFormatter.withFunction(
(oldValue, newValue) => newValue.copyWith(
text: newValue.text.toLowerCase(),
selection: newValue.selection,
),
),
],
decoration: InputDecoration(
labelText: l10n.fieldServerHost,
hintText: 'host[:port]',
prefixIcon: const Icon(Icons.dns_outlined),
border: const OutlineInputBorder(),
),
),
const SizedBox(height: 8),
TextField(
controller: widget.nickCtl,
focusNode: _nickFocus,
onTapOutside: _onTapOutside,
textInputAction: TextInputAction.next,
autocorrect: false,
enableSuggestions: false,
decoration: InputDecoration(
labelText: l10n.fieldNickname,
border: const OutlineInputBorder(),
),
),
const SizedBox(height: 8),
TextField(
controller: widget.passwordCtl,
focusNode: _passwordFocus,
onTapOutside: _onTapOutside,
obscureText: true,
textInputAction: TextInputAction.done,
autocorrect: false,
enableSuggestions: false,
decoration: InputDecoration(
labelText: l10n.fieldServerPassword,
helperText: l10n.fieldServerPasswordHelp,
border: const OutlineInputBorder(),
),
),
const SizedBox(height: 16),
Row(
children: [
Expanded(
child: FilledButton.icon(
icon: const Icon(Icons.login),
label: Text(l10n.connectAction),
onPressed: widget.onConnect,
),
),
const SizedBox(width: 8),
OutlinedButton.icon(
icon: const Icon(Icons.bookmark_add_outlined),
label: Text(l10n.bookmarkAddAction),
onPressed: widget.onAddBookmark,
),
],
),
],
);
}
}
/// Saved bookmark list.
class BookmarkList extends StatelessWidget {
/// Construct a bookmark list.
const BookmarkList({
super.key,
required this.bookmarks,
required this.onConnect,
required this.onDelete,
});
/// Saved bookmarks.
final List<rust.BridgeBookmark> bookmarks;
/// Connect to a bookmark.
final ValueChanged<rust.BridgeBookmark> onConnect;
/// Delete a bookmark.
final ValueChanged<rust.BridgeBookmark> onDelete;
@override
Widget build(BuildContext context) {
final l10n = AppL10n.of(context);
final theme = Theme.of(context);
if (bookmarks.isEmpty) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Text(l10n.bookmarksEmpty, style: theme.textTheme.bodySmall),
);
}
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(l10n.bookmarksHeading, style: theme.textTheme.titleSmall),
const SizedBox(height: 4),
for (final b in bookmarks)
Card(
margin: const EdgeInsets.symmetric(vertical: 4),
child: ListTile(
title: Text(b.displayName),
subtitle: Text('${b.host}${b.nickname}'),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
icon: const Icon(Icons.login),
tooltip: l10n.connectAction,
onPressed: () => onConnect(b),
),
IconButton(
icon: const Icon(Icons.delete_outline),
tooltip: l10n.bookmarkDeleteAction,
onPressed: () => onDelete(b),
),
],
),
),
),
],
);
}
}
@@ -0,0 +1,279 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../l10n/generated/app_localizations.dart';
import '../src/rust/api.dart' as rust;
/// Translate a [LogicalKeyboardKey] into the platform-neutral label
/// stored by the PTT binding flow.
String? pttDisplayLabelForKey(LogicalKeyboardKey k) {
if (k == LogicalKeyboardKey.space) return 'Space';
if (k == LogicalKeyboardKey.enter || k == LogicalKeyboardKey.numpadEnter) {
return 'Enter';
}
if (k == LogicalKeyboardKey.tab) return 'Tab';
if (k == LogicalKeyboardKey.escape) return 'Escape';
if (k == LogicalKeyboardKey.backspace) return 'Backspace';
if (k == LogicalKeyboardKey.delete) return 'Delete';
if (k == LogicalKeyboardKey.insert) return 'Insert';
if (k == LogicalKeyboardKey.home) return 'Home';
if (k == LogicalKeyboardKey.end) return 'End';
if (k == LogicalKeyboardKey.pageUp) return 'Page Up';
if (k == LogicalKeyboardKey.pageDown) return 'Page Down';
if (k == LogicalKeyboardKey.arrowUp) return 'Arrow Up';
if (k == LogicalKeyboardKey.arrowDown) return 'Arrow Down';
if (k == LogicalKeyboardKey.arrowLeft) return 'Arrow Left';
if (k == LogicalKeyboardKey.arrowRight) return 'Arrow Right';
if (k == LogicalKeyboardKey.shift ||
k == LogicalKeyboardKey.shiftLeft ||
k == LogicalKeyboardKey.shiftRight ||
k == LogicalKeyboardKey.control ||
k == LogicalKeyboardKey.controlLeft ||
k == LogicalKeyboardKey.controlRight ||
k == LogicalKeyboardKey.alt ||
k == LogicalKeyboardKey.altLeft ||
k == LogicalKeyboardKey.altRight ||
k == LogicalKeyboardKey.meta ||
k == LogicalKeyboardKey.metaLeft ||
k == LogicalKeyboardKey.metaRight ||
k == LogicalKeyboardKey.capsLock ||
k == LogicalKeyboardKey.numLock ||
k == LogicalKeyboardKey.scrollLock) {
return null;
}
final fallback = k.keyLabel.trim();
if (fallback.isEmpty) return null;
return fallback;
}
/// Result of a successful PTT binding capture.
class CapturedBinding {
const CapturedBinding({required this.inputClass, required this.platformKey});
/// Coarse input class, safe to persist and display.
final rust.BridgePttInputClass inputClass;
/// Opaque platform-neutral key label.
final String platformKey;
}
/// 'Save bookmark' name-entry dialog.
class BookmarkNameDialog extends StatefulWidget {
/// Construct a bookmark-name dialog.
const BookmarkNameDialog({super.key, required this.initialName});
/// Initial display name.
final String initialName;
@override
State<BookmarkNameDialog> createState() => _BookmarkNameDialogState();
}
class _BookmarkNameDialogState extends State<BookmarkNameDialog> {
late final TextEditingController _ctl = TextEditingController(
text: widget.initialName,
);
@override
void dispose() {
_ctl.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final l10n = AppL10n.of(context);
return AlertDialog(
title: Text(l10n.bookmarkAddTitle),
content: TextField(
controller: _ctl,
autofocus: true,
decoration: InputDecoration(labelText: l10n.fieldDisplayName),
onSubmitted: (_) => Navigator.of(context).pop(_ctl.text),
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: Text(l10n.closeAction),
),
FilledButton(
onPressed: () => Navigator.of(context).pop(_ctl.text),
child: Text(l10n.bookmarkAddAction),
),
],
);
}
}
/// Channel-password dialog.
class ChannelPasswordDialog extends StatefulWidget {
/// Construct a channel-password dialog.
const ChannelPasswordDialog({super.key});
@override
State<ChannelPasswordDialog> createState() => _ChannelPasswordDialogState();
}
class _ChannelPasswordDialogState extends State<ChannelPasswordDialog> {
final TextEditingController _ctl = TextEditingController();
@override
void dispose() {
_ctl.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final l10n = AppL10n.of(context);
return AlertDialog(
title: Text(l10n.channelPasswordTitle),
content: TextField(
controller: _ctl,
obscureText: true,
autofocus: true,
decoration: InputDecoration(labelText: l10n.fieldPassword),
onSubmitted: (_) => Navigator.of(context).pop(_ctl.text),
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: Text(l10n.closeAction),
),
FilledButton(
onPressed: () => Navigator.of(context).pop(_ctl.text),
child: Text(l10n.connectAction),
),
],
);
}
}
/// Focus-scoped dialog that captures the next key press or mouse
/// side-button click.
class PttBindingCaptureDialog extends StatefulWidget {
/// Construct a PTT binding capture dialog.
const PttBindingCaptureDialog({super.key});
@override
State<PttBindingCaptureDialog> createState() =>
_PttBindingCaptureDialogState();
}
class _PttBindingCaptureDialogState extends State<PttBindingCaptureDialog> {
final FocusNode _focusNode = FocusNode();
String? _captured;
rust.BridgePttInputClass _capturedClass = rust.BridgePttInputClass.none;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
_focusNode.requestFocus();
});
}
@override
void dispose() {
_focusNode.dispose();
super.dispose();
}
KeyEventResult _onKeyEvent(FocusNode node, KeyEvent event) {
if (event is! KeyDownEvent) return KeyEventResult.ignored;
final label = pttDisplayLabelForKey(event.logicalKey);
if (label == null) return KeyEventResult.ignored;
setState(() {
_captured = label;
_capturedClass = rust.BridgePttInputClass.keyboard;
});
return KeyEventResult.handled;
}
void _captureMouseSideButton(int button) {
setState(() {
_captured = 'mouse-side-button:$button';
_capturedClass = rust.BridgePttInputClass.mouseSideButton;
});
}
@override
Widget build(BuildContext context) {
final l10n = AppL10n.of(context);
final theme = Theme.of(context);
return AlertDialog(
title: Text(l10n.pttConfigureTitle),
content: SizedBox(
width: 360,
child: Focus(
focusNode: _focusNode,
onKeyEvent: _onKeyEvent,
autofocus: true,
child: Listener(
behavior: HitTestBehavior.opaque,
onPointerDown: (e) {
const int back = 0x08;
const int forward = 0x10;
if (e.buttons == back || e.buttons == forward) {
_captureMouseSideButton(e.buttons);
}
},
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
l10n.pttConfigurePrompt,
style: theme.textTheme.bodyMedium,
),
const SizedBox(height: 12),
Container(
padding: const EdgeInsets.symmetric(
vertical: 12,
horizontal: 16,
),
decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(6),
),
child: Text(
_captured == null
? l10n.pttConfigureWaiting
: '${l10n.pttConfigureCaptured}: $_captured',
style: theme.textTheme.bodyMedium?.copyWith(
fontFamily: 'monospace',
),
),
),
const SizedBox(height: 12),
Text(
l10n.pttConfigurePrivacyNote,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
],
),
),
),
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: Text(l10n.closeAction),
),
FilledButton(
onPressed: _captured == null
? null
: () => Navigator.of(context).pop(
CapturedBinding(
inputClass: _capturedClass,
platformKey: _captured!,
),
),
child: Text(l10n.pttConfigureSaveAction),
),
],
);
}
}
@@ -0,0 +1,665 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../l10n/generated/app_localizations.dart';
import '../services/channel_spacer.dart';
import '../services/link_trust_service.dart';
import '../services/snapshot_state_mapper.dart';
import '../services/ts3_server_link.dart';
import '../src/rust/api.dart' as rust;
import 'bbcode_text.dart';
import 'talk_power_warning.dart';
/// Connected-server snapshot with welcome text, channels, and clients.
class SnapshotView extends StatefulWidget {
/// Construct a snapshot view.
const SnapshotView({
super.key,
required this.snapshot,
required this.audioStats,
required this.currentVoiceChannelId,
required this.pendingVoiceChannelId,
required this.localInputMuted,
required this.localOutputMuted,
required this.hasJoinPending,
required this.canJoinVoiceChannel,
required this.onJoinChannel,
required this.onJoinChannelWithPassword,
this.onTs3ServerLink,
});
/// Current bridge snapshot.
final rust.BridgeSnapshot snapshot;
/// Latest audio stats, used for local speaking state.
final rust.BridgeAudioStats? audioStats;
/// Current voice channel id.
final BigInt? currentVoiceChannelId;
/// Pending join target, if any.
final BigInt? pendingVoiceChannelId;
/// Local input mute state.
final bool localInputMuted;
/// Local output mute state.
final bool localOutputMuted;
/// True while a channel join is in flight.
final bool hasJoinPending;
/// True when the local client may join voice channels.
final bool canJoinVoiceChannel;
/// Join an unlocked channel.
final ValueChanged<rust.BridgeChannel> onJoinChannel;
/// Join a password-protected channel.
final ValueChanged<rust.BridgeChannel> onJoinChannelWithPassword;
/// Handle TeamSpeak server links embedded in server-provided text.
final Ts3ServerLinkHandler? onTs3ServerLink;
@override
State<SnapshotView> createState() => _SnapshotViewState();
}
class _SnapshotViewState extends State<SnapshotView> {
static const _indentPerLevel = 12.0;
static const _expandColumnWidth = 28.0;
static const _channelIconColumnWidth = 24.0;
static const _channelTextGap = 8.0;
static const _userRowStartIndent = 32.0;
final _scrollController = ScrollController();
final Map<BigInt, bool> _channelExpandedById = {};
bool _welcomeExpanded = true;
double _welcomeHeight = 0;
final _welcomeKey = GlobalKey();
@override
void initState() {
super.initState();
_scrollController.addListener(_onScroll);
WidgetsBinding.instance.addPostFrameCallback((_) {
final ctx = _welcomeKey.currentContext;
if (ctx != null) {
final box = ctx.findRenderObject() as RenderBox?;
if (box != null && mounted) {
setState(() => _welcomeHeight = box.size.height);
}
}
});
}
void _onScroll() {
if (_welcomeExpanded && _scrollController.offset > _welcomeHeight) {
setState(() => _welcomeExpanded = false);
}
}
@override
void dispose() {
_scrollController.removeListener(_onScroll);
_scrollController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final l10n = AppL10n.of(context);
final theme = Theme.of(context);
final tree = _buildChannelTree(widget.snapshot.channels);
final clientsByChannel = <BigInt, List<rust.BridgeClient>>{};
for (final c in widget.snapshot.clients) {
if (!c.isServerQuery) {
clientsByChannel.putIfAbsent(c.channel, () => []).add(c);
}
}
return ListView(
controller: _scrollController,
children: [
Text(
l10n.countChannelsAndClients(
widget.snapshot.channels.length,
widget.snapshot.clients.length,
),
style: theme.textTheme.bodyMedium,
),
if (widget.snapshot.welcomeMessage.isNotEmpty) ...[
const SizedBox(height: 8),
_WelcomeMessageTile(
key: _welcomeKey,
welcomeMessage: widget.snapshot.welcomeMessage,
expanded: _welcomeExpanded,
onToggle: () =>
setState(() => _welcomeExpanded = !_welcomeExpanded),
onTs3ServerLink: widget.onTs3ServerLink,
),
],
const Divider(height: 24),
for (final node in tree.roots)
..._channelTreeRows(theme, node, clientsByChannel, 0),
],
);
}
List<Widget> _channelTreeRows(
ThemeData theme,
_ChannelTreeNode node,
Map<BigInt, List<rust.BridgeClient>> clientsByChannel,
int depth,
) {
final channel = node.channel;
final clients = clientsByChannel[channel.id] ?? const <rust.BridgeClient>[];
final hasVisibleChildren = clients.isNotEmpty || node.children.isNotEmpty;
final expanded = _isChannelExpanded(channel.id);
final channelIndent = (depth.clamp(0, 8)) * _indentPerLevel;
return [
_channelTile(
theme,
channel,
channelIndent: channelIndent,
hasVisibleChildren: hasVisibleChildren,
expanded: expanded,
onToggleExpanded: hasVisibleChildren
? () => _toggleChannelExpanded(channel.id)
: null,
),
if (expanded) ...[
for (final client in clients) _clientTile(theme, client, channelIndent),
for (final child in node.children)
..._channelTreeRows(theme, child, clientsByChannel, depth + 1),
],
];
}
Widget _channelTile(
ThemeData theme,
rust.BridgeChannel channel, {
required double channelIndent,
required bool hasVisibleChildren,
required bool expanded,
required VoidCallback? onToggleExpanded,
}) {
final spacer = parseSpacerChannelName(channel.name);
final onTap =
widget.hasJoinPending ||
!widget.canJoinVoiceChannel ||
channel.id == widget.currentVoiceChannelId
? null
: () => channel.hasPassword
? widget.onJoinChannelWithPassword(channel)
: widget.onJoinChannel(channel);
if (spacer.isSpacer) {
return InkWell(
onTap: onTap,
child: ConstrainedBox(
constraints: const BoxConstraints(minHeight: 40),
child: Row(
children: [
SizedBox(width: channelIndent),
_expandButton(
theme,
hasVisibleChildren: hasVisibleChildren,
expanded: expanded,
onPressed: onToggleExpanded,
),
const SizedBox(width: _channelTextGap),
Expanded(child: _SpacerChannelContent(spacer: spacer)),
],
),
),
);
}
return InkWell(
onTap: onTap,
child: ConstrainedBox(
constraints: const BoxConstraints(minHeight: 40),
child: Row(
children: [
SizedBox(width: channelIndent),
_expandButton(
theme,
hasVisibleChildren: hasVisibleChildren,
expanded: expanded,
onPressed: onToggleExpanded,
),
SizedBox(
width: _channelIconColumnWidth,
child: Align(
alignment: Alignment.centerLeft,
child: Icon(
Icons.tag,
color: theme.colorScheme.onSurfaceVariant,
),
),
),
const SizedBox(width: _channelTextGap),
Expanded(
child: Text(
channel.name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
if (channel.hasPassword) ...[
const SizedBox(width: 8),
Icon(
Icons.lock_outline,
color: theme.colorScheme.onSurfaceVariant,
),
],
],
),
),
);
}
Widget _clientTile(
ThemeData theme,
rust.BridgeClient client,
double channelIndent,
) {
final status = _clientVoiceStatusIcon(theme, client);
final nameStyle = client.isServerQuery
? TextStyle(color: theme.colorScheme.onSurfaceVariant)
: status.isSpeaking
? TextStyle(
color: theme.colorScheme.primary,
fontWeight: FontWeight.w600,
)
: null;
final decoration = status.isSpeaking
? BoxDecoration(
color: theme.colorScheme.primaryContainer.withValues(alpha: 0.45),
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: theme.colorScheme.primary.withValues(alpha: 0.55),
width: 1.2,
),
boxShadow: [
BoxShadow(
color: theme.colorScheme.primary.withValues(alpha: 0.14),
blurRadius: 8,
spreadRadius: 1,
),
],
)
: null;
return Padding(
padding: EdgeInsets.only(
left: channelIndent + _userRowStartIndent,
right: 8,
),
child: AnimatedContainer(
duration: const Duration(milliseconds: 120),
curve: Curves.easeOut,
decoration: decoration,
child: ListTile(
dense: true,
visualDensity: VisualDensity.compact,
leading: status.icon,
title: Text(client.name, style: nameStyle),
),
),
);
}
Widget _expandButton(
ThemeData theme, {
required bool hasVisibleChildren,
required bool expanded,
required VoidCallback? onPressed,
}) {
if (!hasVisibleChildren) {
return const SizedBox(
width: _expandColumnWidth,
height: _expandColumnWidth,
);
}
return Semantics(
button: true,
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: onPressed,
child: SizedBox(
width: _expandColumnWidth,
height: _expandColumnWidth,
child: Icon(
expanded ? Icons.expand_more : Icons.chevron_right,
color: theme.colorScheme.onSurfaceVariant,
),
),
),
);
}
bool _isChannelExpanded(BigInt channelId) {
return _channelExpandedById[channelId] ?? true;
}
void _toggleChannelExpanded(BigInt channelId) {
setState(() {
_channelExpandedById[channelId] = !_isChannelExpanded(channelId);
});
}
({Widget icon, bool isSpeaking}) _clientVoiceStatusIcon(
ThemeData theme,
rust.BridgeClient client,
) {
final isSelf = client.id == widget.snapshot.ownClientId;
final inCurrentChannel = client.channel == widget.currentVoiceChannelId;
final outputMuted = isSelf ? widget.localOutputMuted : client.outputMuted;
final inputMuted = isSelf ? widget.localInputMuted : client.inputMuted;
final neededTalkPower = snapshotNeededTalkPower(
widget.snapshot,
client.channel,
);
final talkPowerBlocked =
isSelf &&
inCurrentChannel &&
isTalkPowerBlocked(
talkPower: client.talkPower,
neededTalkPower: neededTalkPower,
talkPowerGranted: client.talkPowerGranted,
);
final rawSpeaking = isSelf
? (widget.audioStats?.pttActive ?? false)
: client.isSpeaking;
final transmitAllowed =
!outputMuted &&
!inputMuted &&
(!isSelf || (inCurrentChannel && !talkPowerBlocked));
final speaking = rawSpeaking && transmitAllowed;
final IconData icon;
final Color color;
final String tooltip;
if (outputMuted) {
icon = Icons.volume_off;
color = theme.colorScheme.error;
tooltip = 'Speaker muted';
} else if (inputMuted) {
icon = Icons.mic_off;
color = theme.colorScheme.error;
tooltip = 'Microphone muted';
} else if (talkPowerBlocked) {
icon = Icons.volume_off;
color = theme.colorScheme.error;
tooltip =
'Insufficient talk power (${client.talkPower} < $neededTalkPower)';
} else if (speaking) {
icon = isSelf ? Icons.mic : Icons.volume_up;
color = theme.colorScheme.primary;
tooltip = 'Speaking';
} else if (inCurrentChannel) {
icon = isSelf ? Icons.mic_none : Icons.volume_up_outlined;
color = theme.colorScheme.onSurfaceVariant;
tooltip = 'Not speaking';
} else {
icon = Icons.person_outline;
color = theme.colorScheme.onSurfaceVariant;
tooltip = 'Outside current channel';
}
return (
icon: Tooltip(
message: tooltip,
child: Icon(icon, color: color),
),
isSpeaking: speaking,
);
}
}
class _SpacerChannelContent extends StatelessWidget {
const _SpacerChannelContent({required this.spacer});
final SpacerChannelNameParseResult spacer;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final color = theme.colorScheme.onSurfaceVariant;
if (spacer.isBlankSpacer) {
return const SizedBox(height: 20);
}
if (spacer.specialType != null) {
return SizedBox(
height: 22,
child: CustomPaint(
painter: _SpacerLinePainter(
color: color.withValues(alpha: 0.72),
type: spacer.specialType!,
),
),
);
}
if (spacer.isRepeating) {
return LayoutBuilder(
builder: (context, constraints) {
final pattern = spacer.text.isEmpty ? ' ' : spacer.text;
final estimatedColumns = (constraints.maxWidth / 8).ceil().clamp(
1,
256,
);
return Text(
channelSpacerLabel(
formatSpacerChannelName(
SpacerChannelNameFormatOptions(
alignment: spacer.alignment,
isRepeating: true,
uniqueSuffix: spacer.uniqueSuffix,
text: pattern,
),
),
repeatColumns: estimatedColumns,
),
maxLines: 1,
overflow: TextOverflow.clip,
softWrap: false,
style: theme.textTheme.bodyMedium?.copyWith(color: color),
);
},
);
}
return Text(
spacer.text,
textAlign: switch (spacer.alignment) {
SpacerAlignment.left => TextAlign.left,
SpacerAlignment.right => TextAlign.right,
SpacerAlignment.center => TextAlign.center,
null => TextAlign.center,
},
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.bodyMedium?.copyWith(
color: color,
fontWeight: FontWeight.w600,
),
);
}
}
class _SpacerLinePainter extends CustomPainter {
const _SpacerLinePainter({required this.color, required this.type});
final Color color;
final SpacerSpecialType type;
@override
void paint(Canvas canvas, Size size) {
final y = size.height / 2;
final paint = Paint()
..color = color
..strokeCap = StrokeCap.square
..strokeWidth = 1.4;
switch (type) {
case SpacerSpecialType.solidLine:
canvas.drawLine(Offset(0, y), Offset(size.width, y), paint);
case SpacerSpecialType.dashLine:
_drawPattern(canvas, size.width, y, paint, const [8, 5]);
case SpacerSpecialType.dotLine:
final dotPaint = Paint()..color = color;
for (var x = 1.5; x < size.width; x += 7) {
canvas.drawCircle(Offset(x, y), 1.5, dotPaint);
}
case SpacerSpecialType.dashDotLine:
_drawPattern(canvas, size.width, y, paint, const [10, 4, 2, 4]);
case SpacerSpecialType.dashDotDotLine:
_drawPattern(canvas, size.width, y, paint, const [10, 4, 2, 4, 2, 4]);
}
}
void _drawPattern(
Canvas canvas,
double width,
double y,
Paint paint,
List<double> pattern,
) {
var x = 0.0;
var index = 0;
while (x < width) {
final length = pattern[index % pattern.length];
if (index.isEven) {
final end = x + length > width ? width : x + length;
canvas.drawLine(Offset(x, y), Offset(end, y), paint);
}
x += length;
index += 1;
}
}
@override
bool shouldRepaint(covariant _SpacerLinePainter oldDelegate) {
return oldDelegate.color != color || oldDelegate.type != type;
}
}
class _ChannelTree {
const _ChannelTree({required this.roots});
final List<_ChannelTreeNode> roots;
}
class _ChannelTreeNode {
_ChannelTreeNode(this.channel);
final rust.BridgeChannel channel;
final List<_ChannelTreeNode> children = [];
}
_ChannelTree _buildChannelTree(List<rust.BridgeChannel> channels) {
final byParent = <BigInt, List<rust.BridgeChannel>>{};
final knownIds = {for (final channel in channels) channel.id};
for (final channel in channels) {
final parent = knownIds.contains(channel.parent)
? channel.parent
: BigInt.zero;
byParent.putIfAbsent(parent, () => []).add(channel);
}
_ChannelTreeNode buildNode(rust.BridgeChannel channel) {
final node = _ChannelTreeNode(channel);
for (final child in byParent[channel.id] ?? const <rust.BridgeChannel>[]) {
node.children.add(buildNode(child));
}
return node;
}
return _ChannelTree(
roots: [
for (final channel
in byParent[BigInt.zero] ?? const <rust.BridgeChannel>[])
buildNode(channel),
],
);
}
class _WelcomeMessageTile extends StatelessWidget {
const _WelcomeMessageTile({
super.key,
required this.welcomeMessage,
required this.expanded,
required this.onToggle,
this.onTs3ServerLink,
});
final String welcomeMessage;
final bool expanded;
final VoidCallback onToggle;
final Ts3ServerLinkHandler? onTs3ServerLink;
@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();
onToggle();
},
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(
welcomeMessage,
linkTrust: LinkTrustService.instance,
onTs3ServerLink: onTs3ServerLink,
),
),
secondChild: const SizedBox(width: double.infinity),
crossFadeState: expanded
? CrossFadeState.showFirst
: CrossFadeState.showSecond,
duration: const Duration(milliseconds: 200),
),
],
),
);
}
}
@@ -0,0 +1,60 @@
import 'package:flutter/material.dart';
bool isTalkPowerBlocked({
required int? talkPower,
required int? neededTalkPower,
required bool? talkPowerGranted,
}) {
return talkPower != null &&
neededTalkPower != null &&
talkPower < neededTalkPower &&
talkPowerGranted != true;
}
class TalkPowerWarning extends StatelessWidget {
const TalkPowerWarning({
super.key,
required this.talkPower,
required this.neededTalkPower,
required this.talkPowerGranted,
});
final int? talkPower;
final int? neededTalkPower;
final bool? talkPowerGranted;
@override
Widget build(BuildContext context) {
if (!isTalkPowerBlocked(
talkPower: talkPower,
neededTalkPower: neededTalkPower,
talkPowerGranted: talkPowerGranted,
)) {
return const SizedBox.shrink();
}
final theme = Theme.of(context);
return Container(
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: theme.colorScheme.errorContainer,
borderRadius: BorderRadius.circular(8),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(Icons.warning_amber, size: 18, color: theme.colorScheme.error),
const SizedBox(width: 8),
Expanded(
child: Text(
'Insufficient talk power ($talkPower < $neededTalkPower)',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onErrorContainer,
),
),
),
],
),
);
}
}
+163 -354
View File
@@ -3,14 +3,14 @@
// `BridgeEvent::VoiceState` stream the bridge publishes from the
// core's transmit-mode selector + release-tail timer.
import 'dart:async' show unawaited;
import 'package:flutter/material.dart';
import 'package:haptic_kit/haptic_kit.dart';
import '../l10n/generated/app_localizations.dart';
import 'ptt_capability_badge.dart';
import 'voice_compact.dart';
import 'voice_level_meter.dart';
import 'voice_platform.dart';
import 'voice_status_summary.dart';
import '../src/rust/api.dart' as rust;
/// Voice bar — surfaces the live voice state, mode badge, hard-mute
@@ -30,27 +30,16 @@ class VoiceBar extends StatelessWidget {
required this.pttBackendId,
required this.pttBoundInputClass,
required this.pttBoundKeyLabel,
required this.onToggleMute,
required this.onToggleOutputMute,
required this.onConfigure,
required this.onPttHeldChanged,
this.talkPowerBlocked = false,
});
/// True when the session is currently joined to a voice channel.
final bool inChannel;
/// Active transmit mode.
final rust.BridgeTransmitMode transmitMode;
/// Hard-mute clamp state.
final bool hardMute;
/// Speaker (output) mute state. Mirrors the server-broadcast
/// `ClientOutputMuted` flag plus the engine's local output
/// silencer — toggling this hushes incoming voice immediately
/// AND tells the server so other clients see the headphone-off
/// icon next to our name.
final bool outputMuted;
final bool talkPowerBlocked;
/// Configured release-tail in milliseconds (0..=500). Surfaced as
/// a hint underneath the mode badge.
@@ -77,12 +66,6 @@ class VoiceBar extends StatelessWidget {
/// Platform-neutral key label captured by the binding dialog.
final String pttBoundKeyLabel;
/// Toggle the hard-mute clamp.
final VoidCallback onToggleMute;
/// Toggle speaker (output) mute.
final VoidCallback onToggleOutputMute;
/// Open the voice settings dialog. This is the SINGLE entry point
/// for transmit-mode selection, PTT key binding, and release-tail
/// configuration. The capability badge below is information-only
@@ -98,17 +81,6 @@ class VoiceBar extends StatelessWidget {
/// timer handles the trailing tail (SDD-096).
final ValueChanged<bool> onPttHeldChanged;
String _modeLabel(AppL10n l10n) {
switch (transmitMode) {
case rust.BridgeTransmitMode.ptt:
return l10n.voiceModePtt;
case rust.BridgeTransmitMode.continuous:
return l10n.voiceModeContinuous;
case rust.BridgeTransmitMode.voiceActivity:
return l10n.voiceModeVoiceActivity;
}
}
@override
Widget build(BuildContext context) {
final l10n = AppL10n.of(context);
@@ -116,344 +88,181 @@ class VoiceBar extends StatelessWidget {
final stats = audioStats;
final levelActive = stats?.pttActive ?? false;
final isPtt = transmitMode == rust.BridgeTransmitMode.ptt;
final summary = voiceStatusSummary(
l10n: l10n,
transmitMode: transmitMode,
releaseTailMs: releaseTailMs,
pttBoundKeyLabel: pttBoundKeyLabel,
isTouchOnly: isTouchOnlyPttHost,
inputMuted: hardMute,
outputMuted: outputMuted,
pttActive: stats?.pttActive ?? false,
talkPower: talkPowerBlocked ? 0 : null,
neededTalkPower: talkPowerBlocked ? 1 : null,
talkPowerGranted: false,
);
return Card(
margin: EdgeInsets.zero,
child: Padding(
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Row 1: channel pill + mute toggle. The pill is
// wrapped in `Flexible` so very long channel names
// truncate with an ellipsis instead of overflowing the
// Voice Bar's column width (320 dp in the wide layout)
// and pushing the mute icons under the adjacent channel
// tree.
Row(
children: [
if (inChannel && channelName.isNotEmpty) ...[
Flexible(
flex: 100,
fit: FlexFit.loose,
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 10,
vertical: 4,
),
decoration: BoxDecoration(
color: theme.colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(12),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.tag,
size: 14,
color: theme.colorScheme.onPrimaryContainer,
),
const SizedBox(width: 4),
Flexible(
child: Text(
channelName,
maxLines: 1,
overflow: TextOverflow.ellipsis,
softWrap: false,
style: TextStyle(
color: theme.colorScheme.onPrimaryContainer,
fontWeight: FontWeight.w600,
),
),
),
],
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 0),
child: Container(
decoration: BoxDecoration(
color: summary.talkPowerBlocked
? Colors.amber.withValues(alpha: 0.18)
: summary.muted
? theme.colorScheme.errorContainer.withValues(alpha: 0.35)
: theme.colorScheme.surfaceContainerHigh,
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: summary.talkPowerBlocked
? Colors.amber.shade700
: summary.muted
? theme.colorScheme.error
: theme.colorScheme.outlineVariant,
width: summary.talkPowerBlocked || summary.muted ? 1.5 : 0.5,
),
),
child: Padding(
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Row 1: mode badge
Row(
children: [
Icon(
transmitMode == rust.BridgeTransmitMode.ptt
? Icons.radio_button_checked
: Icons.podcasts,
size: 16,
color: theme.colorScheme.onSurfaceVariant,
),
const SizedBox(width: 6),
Expanded(
child: Text(
summary.modeLabel,
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
),
TextButton.icon(
icon: const Icon(Icons.tune, size: 16),
label: Text(l10n.voiceSettingsTitle),
onPressed: onConfigure,
),
],
const SizedBox(width: 8),
const Spacer(),
IconButton(
tooltip: l10n.voiceOutputMuteLabel,
icon: Icon(outputMuted ? Icons.headset_off : Icons.headset),
isSelected: outputMuted,
selectedIcon: const Icon(Icons.headset_off),
onPressed: onToggleOutputMute,
),
IconButton(
tooltip: l10n.voiceHardMuteLabel,
icon: Icon(hardMute ? Icons.mic_off : Icons.mic),
isSelected: hardMute,
selectedIcon: const Icon(Icons.mic_off),
onPressed: onToggleMute,
),
// Status row: talk power / mic / speaker state.
if (inChannel) ...[
const SizedBox(height: 2),
Text(
summary.talkPowerBlocked
? 'Insufficient talk power'
: summary.statusText,
style: theme.textTheme.bodySmall?.copyWith(
color: summary.talkPowerBlocked
? Colors.amber.shade700
: summary.muted
? theme.colorScheme.error
: theme.colorScheme.onSurfaceVariant,
),
),
],
),
const SizedBox(height: 6),
// Row 2: mode badge
Row(
children: [
Icon(
transmitMode == rust.BridgeTransmitMode.ptt
? Icons.radio_button_checked
: Icons.podcasts,
size: 16,
color: theme.colorScheme.onSurfaceVariant,
),
const SizedBox(width: 6),
Expanded(
// Row 3: PTT-only secondary content.
//
// On hardware-keyboard hosts (Windows / macOS / Linux /
// Web) this is a one-line bound-key + release-tail hint
// sitting right under the mode badge.
//
// On touch-only hosts (iOS / iPadOS / Android) the
// on-screen Push to Talk button is rendered AT THE
// BOTTOM of the Voice Bar (see below) so it sits
// closest to the user's thumb when the Voice Bar is
// pinned to the bottom of a narrow-layout screen. The
// release-tail value is folded into the small print
// under the button rather than shown here.
if (isPtt && !isTouchOnlyPttHost)
Padding(
padding: const EdgeInsets.only(left: 22, top: 2),
child: Text(
_modeLabel(l10n),
style: theme.textTheme.bodyMedium?.copyWith(
'${l10n.voiceModePtt}: '
'${pttBoundKeyLabel.isEmpty ? "" : pttBoundKeyLabel}'
' · '
'${l10n.voiceReleaseTailLabel}: $releaseTailMs${l10n.voiceReleaseTailHint}',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
),
TextButton.icon(
icon: const Icon(Icons.tune, size: 16),
label: Text(l10n.voiceSettingsTitle),
onPressed: onConfigure,
),
],
),
// Row 3: PTT-only secondary content.
//
// On hardware-keyboard hosts (Windows / macOS / Linux /
// Web) this is a one-line bound-key + release-tail hint
// sitting right under the mode badge.
//
// On touch-only hosts (iOS / iPadOS / Android) the
// on-screen Push to Talk button is rendered AT THE
// BOTTOM of the Voice Bar (see below) so it sits
// closest to the user's thumb when the Voice Bar is
// pinned to the bottom of a narrow-layout screen. The
// release-tail value is folded into the small print
// under the button rather than shown here.
if (isPtt && !isTouchOnlyPttHost)
Padding(
padding: const EdgeInsets.only(left: 22, top: 2),
child: Text(
'${l10n.voiceModePtt}: '
'${pttBoundKeyLabel.isEmpty ? "" : pttBoundKeyLabel}'
' · '
'${l10n.voiceReleaseTailLabel}: $releaseTailMs${l10n.voiceReleaseTailHint}',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
),
const SizedBox(height: 6),
// Row 4: level meter
_LevelMeter(active: levelActive),
const SizedBox(height: 4),
if (stats != null)
Text(
l10n.audioStatsLine(
stats.framesSent,
stats.framesReceived,
stats.pttActive ? l10n.voiceMicOn : l10n.voiceMicOff,
),
style: theme.textTheme.bodySmall,
),
const SizedBox(height: 6),
// PTT capability badge — only relevant when PTT mode is
// active. Hidden for Continuous / Voice Activity since
// there's no key binding to surface a capability for.
// The badge is information-only; the user reaches the
// bind-key flow through the Voice Bar's settings gear
// (single configuration entry point — see the comment
// on `onConfigure`).
//
// Still shown on touch-only mobile hosts because iOS P0
// acceptance requires an explicit `L0Focused` badge and
// explanation that global hotkeys are not available in
// the iOS sandbox.
if (isPtt)
PttCapabilityBadge(
level: pttLevel,
backendId: pttBackendId,
boundInputClass: pttBoundInputClass,
),
// On touch-only mobile hosts the Push to Talk button is
// the LAST element of the Voice Bar so it lands closest
// to the user's thumb when the Voice Bar is pinned to
// the bottom of a narrow-layout screen. The release-
// tail value sits above the button so the user sees
// how long their voice continues after they let go.
if (isPtt && isTouchOnlyPttHost) ...[
const SizedBox(height: 6),
// Row 4: level meter
VoiceLevelMeter(active: levelActive),
const SizedBox(height: 4),
Center(
child: Text(
'${l10n.voiceReleaseTailLabel}: $releaseTailMs${l10n.voiceReleaseTailHint}',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
if (stats != null)
Text(
l10n.audioStatsLine(
stats.framesSent,
stats.framesReceived,
stats.pttActive ? l10n.voiceMicOn : l10n.voiceMicOff,
),
style: theme.textTheme.bodySmall,
),
),
const SizedBox(height: 8),
_PttHoldButton(
active: levelActive,
onHeldChanged: onPttHeldChanged,
),
],
// Leave-voice button intentionally absent: TeamSpeak's
// model is "user is always in some channel", not
// Discord's join/leave-voice. To stop being heard /
// hearing others, mute mic and/or speaker via the
// icons at the top of the bar. To physically move,
// tap a different channel in the tree below.
],
),
),
);
}
}
class _LevelMeter extends StatelessWidget {
const _LevelMeter({required this.active});
final bool active;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Container(
height: 8,
decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(4),
),
child: FractionallySizedBox(
alignment: AlignmentDirectional.centerStart,
widthFactor: active ? 0.75 : 0.05,
child: Container(
decoration: BoxDecoration(
color: active
? theme.colorScheme.primary
: theme.colorScheme.outlineVariant,
borderRadius: BorderRadius.circular(4),
),
),
),
);
}
}
/// On-screen push-to-talk button for touch-only mobile platforms
/// (iOS / iPadOS / Android). Hardware-keyboard hosts hide this in
/// favour of a bound key.
///
/// Behaviour:
/// * `onPanDown` (finger touches the button) → fires
/// `onHeldChanged(true)`. The Rust release-tail timer treats
/// this as `key_down`.
/// * `onPanEnd` / `onPanCancel` (finger lifts or drags off) →
/// fires `onHeldChanged(false)` → `key_up` → tail expires →
/// mic closes.
///
/// Using `GestureDetector` rather than `Listener` because we want
/// gesture-arena semantics: if the user starts dragging the
/// channel-tree underneath, the PTT should release. `onPanCancel`
/// fires in that case.
///
/// The button visually mirrors the `_LevelMeter` state via the
/// `active` flag so the user gets feedback that holding actually
/// engaged the mic.
class _PttHoldButton extends StatefulWidget {
const _PttHoldButton({required this.active, required this.onHeldChanged});
final bool active;
final ValueChanged<bool> onHeldChanged;
@override
State<_PttHoldButton> createState() => _PttHoldButtonState();
}
class _PttHoldButtonState extends State<_PttHoldButton> {
bool _pressed = false;
@override
void initState() {
super.initState();
unawaited(Haptics.prepare().catchError((_) => false));
}
void _setHeld(bool held) {
if (_pressed == held) return;
setState(() => _pressed = held);
widget.onHeldChanged(held);
_playPressHaptic(held);
}
void _playPressHaptic(bool held) {
final haptic = held
? Haptics.impact(HapticImpactStyle.medium)
: Haptics.selection();
unawaited(haptic.catchError((_) {}));
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final activeNow = _pressed || widget.active;
final l10n = AppL10n.of(context);
return Semantics(
button: true,
liveRegion: true,
label: activeNow ? l10n.pttTransmitting : l10n.pttHoldToTalk,
hint: l10n.pttHoldToTalkSemanticsHint,
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTapDown: (_) => _setHeld(true),
onTapUp: (_) => _setHeld(false),
onTapCancel: () => _setHeld(false),
onPanDown: (_) => _setHeld(true),
onPanEnd: (_) => _setHeld(false),
onPanCancel: () => _setHeld(false),
child: ExcludeSemantics(
child: AnimatedContainer(
duration: const Duration(milliseconds: 80),
height: 64,
decoration: BoxDecoration(
color: activeNow
? theme.colorScheme.primary
: theme.colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(12),
boxShadow: activeNow
? [
BoxShadow(
color: theme.colorScheme.primary.withAlpha(100),
blurRadius: 12,
offset: const Offset(0, 2),
),
]
: null,
),
child: Center(
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
activeNow ? Icons.mic : Icons.mic_none,
color: activeNow
? theme.colorScheme.onPrimary
: theme.colorScheme.onPrimaryContainer,
size: 24,
),
const SizedBox(width: 10),
Text(
activeNow ? l10n.voiceMicOn : l10n.voiceModePtt,
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
color: activeNow
? theme.colorScheme.onPrimary
: theme.colorScheme.onPrimaryContainer,
const SizedBox(height: 6),
// PTT capability badge — only relevant when PTT mode is
// active. Hidden for Continuous / Voice Activity since
// there's no key binding to surface a capability for.
// The badge is information-only; the user reaches the
// bind-key flow through the Voice Bar's settings gear
// (single configuration entry point — see the comment
// on `onConfigure`).
//
// Still shown on touch-only mobile hosts because iOS P0
// acceptance requires an explicit `L0Focused` badge and
// explanation that global hotkeys are not available in
// the iOS sandbox.
if (isPtt)
PttCapabilityBadge(
level: pttLevel,
backendId: pttBackendId,
boundInputClass: pttBoundInputClass,
),
// On touch-only mobile hosts the Push to Talk button is
// the LAST element of the Voice Bar so it lands closest
// to the user's thumb when the Voice Bar is pinned to
// the bottom of a narrow-layout screen. The release-
// tail value sits above the button so the user sees
// how long their voice continues after they let go.
if (isPtt && isTouchOnlyPttHost) ...[
const SizedBox(height: 4),
Center(
child: Text(
'${l10n.voiceReleaseTailLabel}: $releaseTailMs${l10n.voiceReleaseTailHint}',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
],
),
),
),
const SizedBox(height: 8),
VoicePttButton(
active: levelActive,
onHeldChanged: onPttHeldChanged,
height: 64,
borderRadius: 12,
iconSize: 24,
iconGap: 10,
blurRadius: 12,
spreadRadius: 0,
listenForPan: true,
labelLetterSpacing: null,
),
],
// Leave-voice button intentionally absent: TeamSpeak's
// model is "user is always in some channel", not
// Discord's join/leave-voice. To stop being heard /
// hearing others, mute mic and/or speaker via the
// icons at the top of the bar. To physically move,
// tap a different channel in the tree below.
],
),
),
),
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,16 @@
import 'dart:async' show unawaited;
import 'package:haptic_kit/haptic_kit.dart';
/// Prepare mobile haptics without surfacing platform failures.
void prepareVoiceHaptics() {
unawaited(Haptics.prepare().catchError((_) => false));
}
/// Play the standard touch PTT haptic without surfacing platform failures.
void playVoicePttHaptic(bool held) {
final haptic = held
? Haptics.impact(HapticImpactStyle.medium)
: Haptics.selection();
unawaited(haptic.catchError((_) {}));
}
@@ -0,0 +1,32 @@
import 'package:flutter/material.dart';
/// Shared compact level meter used by voice surfaces.
class VoiceLevelMeter extends StatelessWidget {
const VoiceLevelMeter({super.key, required this.active});
final bool active;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Container(
height: 8,
decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(4),
),
child: FractionallySizedBox(
alignment: AlignmentDirectional.centerStart,
widthFactor: active ? 0.75 : 0.05,
child: Container(
decoration: BoxDecoration(
color: active
? theme.colorScheme.primary
: theme.colorScheme.outlineVariant,
borderRadius: BorderRadius.circular(4),
),
),
),
);
}
}
@@ -7,15 +7,19 @@
// - VAD backend
// - iOS voice processing mode
// ignore_for_file: deprecated_member_use
import 'dart:io' show Platform;
import 'package:flutter/foundation.dart' show kIsWeb;
import 'package:flutter/material.dart';
import '../l10n/generated/app_localizations.dart';
import 'audio_device_list_tile.dart';
import 'audio_output_tile.dart';
import 'audio_processing_config_state.dart';
import 'ptt_capability_badge.dart';
import 'talk_power_warning.dart';
import 'voice_platform.dart';
import 'voice_settings_controls.dart';
import '../src/rust/api.dart' as rust;
bool get _isAndroid {
@@ -50,11 +54,23 @@ class VoiceSettingsDialog extends StatefulWidget {
required this.initialMode,
required this.initialReleaseTailMs,
required this.initialAudioConfig,
this.pttLevel = '',
this.pttBackendId = '',
this.pttBoundInputClass = '',
this.talkPower,
this.neededTalkPower,
this.talkPowerGranted,
});
final rust.BridgeTransmitMode initialMode;
final int initialReleaseTailMs;
final rust.BridgeAudioProcessingConfig initialAudioConfig;
final String pttLevel;
final String pttBackendId;
final String pttBoundInputClass;
final int? talkPower;
final int? neededTalkPower;
final bool? talkPowerGranted;
@override
State<VoiceSettingsDialog> createState() => _VoiceSettingsDialogState();
@@ -64,16 +80,7 @@ class _VoiceSettingsDialogState extends State<VoiceSettingsDialog> {
late rust.BridgeTransmitMode _mode;
late double _releaseTail;
// Audio processing state — mirrors BridgeAudioProcessingConfig fields.
late bool _nsEnabled;
late bool _aecEnabled;
late bool _agcEnabled;
late bool _hpfEnabled;
late bool _limiterEnabled;
late rust.BridgeVadBackend _vadBackend;
late rust.BridgeIosVoiceProcessingMode _iosMode;
late bool _debugWavDump;
late bool _preferHardware; // Android only: try JNI hardware effects
late final AudioProcessingConfigState _audioProcessing;
@override
void initState() {
@@ -81,93 +88,15 @@ class _VoiceSettingsDialogState extends State<VoiceSettingsDialog> {
_mode = widget.initialMode;
_releaseTail = widget.initialReleaseTailMs.clamp(0, 500).toDouble();
final c = widget.initialAudioConfig;
_nsEnabled = c.ns != rust.BridgeEffectOwner.off;
_aecEnabled = c.aec != rust.BridgeEffectOwner.off;
_agcEnabled = c.agc != rust.BridgeEffectOwner.off;
_hpfEnabled = c.hpfEnabled;
_limiterEnabled = c.limiterEnabled;
_vadBackend = c.vadBackend == rust.BridgeVadBackend.disabled
? rust.BridgeVadBackend.webrtcVad
: c.vadBackend;
_iosMode = c.iosMode;
_debugWavDump = c.debugWavDumpEnabled;
_preferHardware = c.aec == rust.BridgeEffectOwner.platform
|| c.ns == rust.BridgeEffectOwner.platform
|| c.agc == rust.BridgeEffectOwner.platform;
_audioProcessing = AudioProcessingConfigState.fromConfig(
widget.initialAudioConfig,
);
}
rust.BridgeAudioProcessingConfig _buildConfig() {
final c = widget.initialAudioConfig;
final isSonora =
_iosMode == rust.BridgeIosVoiceProcessingMode.sonoraExperimental;
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.webrtcApm
: rust.BridgeEffectOwner.off)
: rust.BridgeEffectOwner.platform; // VPIO always owns AEC
final nsOwner = isSonora
? (_nsEnabled
? rust.BridgeEffectOwner.webrtcApm
: rust.BridgeEffectOwner.off)
: (_nsEnabled
? rust.BridgeEffectOwner.platform
: rust.BridgeEffectOwner.off);
final agcOwner = isSonora
? (_agcEnabled
? rust.BridgeEffectOwner.webrtcApm
: rust.BridgeEffectOwner.off)
: (_agcEnabled
? rust.BridgeEffectOwner.platform
: rust.BridgeEffectOwner.off);
final vadBackend = _vadBackend == rust.BridgeVadBackend.disabled
? rust.BridgeVadBackend.webrtcVad
: _vadBackend;
return rust.BridgeAudioProcessingConfig(
route: c.route,
iosMode: _iosMode,
processingBackend: isSonora
? rust.BridgeAudioBackend.webrtcApm
: rust.BridgeAudioBackend.platformVoiceProcessing,
vadBackend: vadBackend,
aec: aecOwner,
ns: nsOwner,
agc: agcOwner,
hpfEnabled: _hpfEnabled,
limiterEnabled: _limiterEnabled,
vadHangoverMs: c.vadHangoverMs,
vadPreRollMs: c.vadPreRollMs,
vadMinTxMs: c.vadMinTxMs,
debugWavDumpEnabled: _debugWavDump,
return _audioProcessing.buildConfig(
base: widget.initialAudioConfig,
isAndroid: _isAndroid,
);
}
@@ -176,7 +105,8 @@ class _VoiceSettingsDialogState extends State<VoiceSettingsDialog> {
final l10n = AppL10n.of(context);
final theme = Theme.of(context);
final platformVpio =
_iosMode == rust.BridgeIosVoiceProcessingMode.platformVoiceProcessing;
_audioProcessing.iosMode ==
rust.BridgeIosVoiceProcessingMode.platformVoiceProcessing;
return AlertDialog(
title: Text(l10n.voiceSettingsTitle),
contentPadding: const EdgeInsets.fromLTRB(24, 16, 24, 0),
@@ -188,24 +118,12 @@ class _VoiceSettingsDialogState extends State<VoiceSettingsDialog> {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// ── Transmit mode ──────────────────────────────────────
_sectionHeader(theme, l10n.voiceModeLabel),
_radioTile<rust.BridgeTransmitMode>(
value: rust.BridgeTransmitMode.ptt,
groupValue: _mode,
title: Text(l10n.voiceModePtt),
onSelected: (v) => _mode = v,
),
_radioTile<rust.BridgeTransmitMode>(
value: rust.BridgeTransmitMode.continuous,
groupValue: _mode,
title: Text(l10n.voiceModeContinuous),
onSelected: (v) => _mode = v,
),
_radioTile<rust.BridgeTransmitMode>(
value: rust.BridgeTransmitMode.voiceActivity,
groupValue: _mode,
title: Text(l10n.voiceModeVoiceActivity),
onSelected: (v) => _mode = v,
VoiceSectionHeader(l10n.voiceModeLabel),
SegmentedButton<rust.BridgeTransmitMode>(
style: voiceSegmentedButtonStyle(theme),
segments: transmitModeSegments,
selected: {_mode},
onSelectionChanged: (s) => setState(() => _mode = s.first),
),
// ── PTT options ────────────────────────────────────────
@@ -257,133 +175,148 @@ class _VoiceSettingsDialogState extends State<VoiceSettingsDialog> {
// ── Audio processing ───────────────────────────────────
const Divider(height: 24),
_sectionHeader(theme, 'Audio processing'),
const VoiceSectionHeader('Audio processing'),
// iOS mode selector (iOS only)
if (_isIos) ...[
_subHeader(theme, 'Processing backend'),
_radioTile<rust.BridgeIosVoiceProcessingMode>(
value:
rust.BridgeIosVoiceProcessingMode.platformVoiceProcessing,
groupValue: _iosMode,
title: const Text('Platform (VPIO)'),
subtitle: _tileSubtitle('Apple AEC · NS · AGC'),
onSelected: (v) => _iosMode = v,
),
_radioTile<rust.BridgeIosVoiceProcessingMode>(
value: rust.BridgeIosVoiceProcessingMode.sonoraExperimental,
groupValue: _iosMode,
title: const Text('Sonora (experimental)'),
subtitle: _tileSubtitle('Rust AEC3 · NS · AGC2'),
onSelected: (v) => _iosMode = v,
const VoiceSubHeader('Processing backend'),
SegmentedButton<rust.BridgeIosVoiceProcessingMode>(
style: voiceSegmentedButtonStyle(theme),
segments: iosProcessingSegments,
selected: {_audioProcessing.iosMode},
onSelectionChanged: (s) =>
setState(() => _audioProcessing.iosMode = s.first),
),
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 VoiceSubHeader('Processing backend'),
SegmentedButton<bool>(
style: voiceSegmentedButtonStyle(theme),
segments: androidProcessingSegments,
selected: {_audioProcessing.preferHardware},
onSelectionChanged: (s) =>
setState(() => _audioProcessing.preferHardware = s.first),
),
const SizedBox(height: 4),
],
// DSP toggles
_subHeader(theme, 'DSP stages'),
_switchTile(
title: 'Noise suppression (NS)',
const VoiceSubHeader('DSP stages'),
AudioProcessingToggleRow(
label: 'Noise suppression (NS)',
subtitle: 'Wiener filter · stationary noise',
value: _nsEnabled,
onSelected: (v) => _nsEnabled = v,
value: _audioProcessing.nsEnabled,
onChanged: (v) =>
setState(() => _audioProcessing.nsEnabled = v),
),
_switchTile(
title: 'Echo cancellation (AEC3)',
AudioProcessingToggleRow(
label: 'Echo cancellation (AEC3)',
subtitle: _isAndroid
? 'WebRTC AEC3 · adaptive filter'
: platformVpio
? 'Managed by platform VPIO'
: 'Adaptive NLMS · 80 ms tail',
value: _aecEnabled,
? 'Managed by platform VPIO'
: 'Adaptive NLMS · 80 ms tail',
value: _audioProcessing.aecEnabled,
// AEC is always on in VPIO mode — disable the toggle.
onSelected: (_isAndroid || !platformVpio) ? (v) => _aecEnabled = v : null,
onChanged: (_isAndroid || !platformVpio)
? (v) => setState(() => _audioProcessing.aecEnabled = v)
: null,
),
_switchTile(
title: 'Auto gain control (AGC2)',
AudioProcessingToggleRow(
label: 'Auto gain control (AGC2)',
subtitle: 'RNN VAD-gated · 18 dBFS target',
value: _agcEnabled,
onSelected: (v) => _agcEnabled = v,
value: _audioProcessing.agcEnabled,
onChanged: (v) =>
setState(() => _audioProcessing.agcEnabled = v),
),
_switchTile(
title: 'High-pass filter (HPF)',
AudioProcessingToggleRow(
label: 'High-pass filter (HPF)',
subtitle: '80 Hz Butterworth · DC removal',
value: _hpfEnabled,
onSelected: (v) => _hpfEnabled = v,
value: _audioProcessing.hpfEnabled,
onChanged: (v) =>
setState(() => _audioProcessing.hpfEnabled = v),
),
_switchTile(
title: 'Peak limiter',
AudioProcessingToggleRow(
label: 'Peak limiter',
subtitle: '1 dBFS soft-knee · 2 ms look-ahead',
value: _limiterEnabled,
onSelected: (v) => _limiterEnabled = v,
value: _audioProcessing.limiterEnabled,
onChanged: (v) =>
setState(() => _audioProcessing.limiterEnabled = v),
),
if (isTalkPowerBlocked(
talkPower: widget.talkPower,
neededTalkPower: widget.neededTalkPower,
talkPowerGranted: widget.talkPowerGranted,
)) ...[
const SizedBox(height: 8),
TalkPowerWarning(
talkPower: widget.talkPower,
neededTalkPower: widget.neededTalkPower,
talkPowerGranted: widget.talkPowerGranted,
),
],
// ── VAD ────────────────────────────────────────────────
const Divider(height: 24),
_sectionHeader(theme, 'Voice activity detection (VAD)'),
const VoiceSectionHeader('Voice activity detection (VAD)'),
_subHeader(theme, 'Backend'),
_radioTile<rust.BridgeVadBackend>(
value: rust.BridgeVadBackend.webrtcVad,
groupValue: _vadBackend,
title: const Text('WebRTC VAD'),
subtitle: _tileSubtitle(
'Fast · energy-based · always available',
),
onSelected: (v) => _vadBackend = v,
),
_radioTile<rust.BridgeVadBackend>(
value: rust.BridgeVadBackend.sileroOnnx,
groupValue: _vadBackend,
title: const Text('Silero v6 (ONNX)'),
subtitle: _tileSubtitle(
'Neural · 32 ms frames · requires model file',
),
onSelected: (v) => _vadBackend = v,
),
_radioTile<rust.BridgeVadBackend>(
value: rust.BridgeVadBackend.tenVad,
groupValue: _vadBackend,
title: const Text('TEN VAD'),
subtitle: _tileSubtitle(
'Neural · 16 kHz · native runtime optional',
),
onSelected: (v) => _vadBackend = v,
const VoiceSubHeader('Backend'),
SegmentedButton<rust.BridgeVadBackend>(
style: voiceSegmentedButtonStyle(theme),
segments: vadBackendSegments,
selected: {_audioProcessing.vadBackend},
onSelectionChanged: (s) =>
setState(() => _audioProcessing.vadBackend = s.first),
),
const SizedBox(height: 8),
// ── PTT capability badge ────────────────────────────────
if (_mode == rust.BridgeTransmitMode.ptt &&
widget.pttLevel.isNotEmpty) ...[
const Divider(height: 24),
const VoiceSectionHeader('PTT capability'),
PttCapabilityBadge(
level: widget.pttLevel,
backendId: widget.pttBackendId,
boundInputClass: widget.pttBoundInputClass,
),
],
// ── Audio output route picker (mobile only) ─────────────
if (_isAndroid || _isIos) ...[
const Divider(height: 24),
const VoiceSectionHeader('Audio output'),
const AudioOutputTile(),
],
// ── Audio devices (desktop only, SRS-026) ──────────────
if (!_isAndroid && !_isIos) ...[
const Divider(height: 24),
const VoiceSectionHeader('Audio devices'),
const AudioDeviceListTile(
label: 'Input',
kind: AudioDeviceKind.input,
),
const AudioDeviceListTile(
label: 'Output',
kind: AudioDeviceKind.output,
),
const SizedBox(height: 8),
],
// ── Debug ──────────────────────────────────────────────
const Divider(height: 24),
_sectionHeader(theme, 'Debug'),
_switchTile(
title: 'WAV dump',
const VoiceSectionHeader('Debug'),
AudioProcessingToggleRow(
label: 'WAV dump',
subtitle: 'Record raw/processed mic to temp dir',
value: _debugWavDump,
onSelected: (v) => _debugWavDump = v,
value: _audioProcessing.debugWavDump,
onChanged: (v) =>
setState(() => _audioProcessing.debugWavDump = v),
),
const SizedBox(height: 8),
],
@@ -409,54 +342,4 @@ class _VoiceSettingsDialogState extends State<VoiceSettingsDialog> {
],
);
}
Widget _radioTile<T>({
required T value,
required T groupValue,
required Widget title,
Widget? subtitle,
required ValueChanged<T> onSelected,
}) => RadioListTile<T>(
dense: true,
value: value,
groupValue: groupValue,
title: title,
subtitle: subtitle,
onChanged: (v) {
if (v == null) return;
setState(() => onSelected(v));
},
);
Widget _switchTile({
required String title,
required String subtitle,
required bool value,
required ValueChanged<bool>? onSelected,
}) => SwitchListTile(
dense: true,
title: Text(title),
subtitle: _tileSubtitle(subtitle),
value: value,
onChanged: onSelected == null ? null : (v) => setState(() => onSelected(v)),
);
Widget _tileSubtitle(String text) =>
Text(text, style: const TextStyle(fontSize: 11));
Widget _sectionHeader(ThemeData theme, String text) => Padding(
padding: const EdgeInsets.only(bottom: 4),
child: Text(text, style: theme.textTheme.titleSmall),
);
Widget _subHeader(ThemeData theme, String text) => Padding(
padding: const EdgeInsets.only(top: 8, bottom: 2),
child: Text(
text,
style: theme.textTheme.labelSmall?.copyWith(
color: theme.colorScheme.primary,
letterSpacing: 0.5,
),
),
);
}
@@ -0,0 +1,195 @@
import 'package:flutter/material.dart';
import '../src/rust/api.dart' as rust;
/// Shared compact style for voice settings segmented buttons.
ButtonStyle voiceSegmentedButtonStyle(ThemeData theme) {
return SegmentedButton.styleFrom(
textStyle: theme.textTheme.labelSmall,
visualDensity: VisualDensity.compact,
);
}
/// Transmit mode selector segments.
const transmitModeSegments = [
ButtonSegment(
value: rust.BridgeTransmitMode.ptt,
label: Text('PTT'),
icon: Icon(Icons.radio_button_checked, size: 14),
),
ButtonSegment(
value: rust.BridgeTransmitMode.continuous,
label: Text('Always'),
icon: Icon(Icons.podcasts, size: 14),
),
ButtonSegment(
value: rust.BridgeTransmitMode.voiceActivity,
label: Text('VAD'),
icon: Icon(Icons.graphic_eq, size: 14),
),
];
/// Android hardware/WebRTC selector segments.
const androidProcessingSegments = [
ButtonSegment(
value: true,
label: Text('Hardware'),
icon: Icon(Icons.phone_android, size: 14),
),
ButtonSegment(
value: false,
label: Text('WebRTC'),
icon: Icon(Icons.science_outlined, size: 14),
),
];
/// iOS VPIO/Sonora selector segments.
const iosProcessingSegments = [
ButtonSegment(
value: rust.BridgeIosVoiceProcessingMode.platformVoiceProcessing,
label: Text('VPIO'),
icon: Icon(Icons.phone_iphone, size: 14),
),
ButtonSegment(
value: rust.BridgeIosVoiceProcessingMode.sonoraExperimental,
label: Text('Sonora'),
icon: Icon(Icons.science_outlined, size: 14),
),
];
/// Voice activity detector selector segments.
const vadBackendSegments = [
ButtonSegment(
value: rust.BridgeVadBackend.webrtcVad,
label: Text('WebRTC'),
icon: Icon(Icons.speed, size: 14),
),
ButtonSegment(
value: rust.BridgeVadBackend.sileroOnnx,
label: Text('Silero'),
icon: Icon(Icons.psychology, size: 14),
),
ButtonSegment(
value: rust.BridgeVadBackend.tenVad,
label: Text('TEN'),
icon: Icon(Icons.graphic_eq, size: 14),
),
];
/// Section subheader used by both voice settings surfaces.
class VoiceSubHeader extends StatelessWidget {
/// Construct a voice settings subheader.
const VoiceSubHeader(this.text, {super.key});
/// Header text.
final String text;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Padding(
padding: const EdgeInsets.only(top: 8, bottom: 2),
child: Text(
text,
style: theme.textTheme.labelSmall?.copyWith(
color: theme.colorScheme.primary,
letterSpacing: 0.5,
),
),
);
}
}
/// Section header used by the full voice settings dialog.
class VoiceSectionHeader extends StatelessWidget {
/// Construct a voice settings section header.
const VoiceSectionHeader(this.text, {super.key});
/// Header text.
final String text;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Padding(
padding: const EdgeInsets.only(bottom: 4),
child: Text(text, style: theme.textTheme.titleSmall),
);
}
}
/// Shared audio-processing switch row.
class AudioProcessingToggleRow extends StatelessWidget {
/// Construct an audio-processing switch row.
const AudioProcessingToggleRow({
super.key,
required this.label,
required this.subtitle,
required this.value,
required this.onChanged,
this.dense = false,
});
/// Primary row label.
final String label;
/// Secondary row detail.
final String subtitle;
/// Current switch value.
final bool value;
/// Called when the switch changes. Null disables the row.
final ValueChanged<bool>? onChanged;
/// Use the compact inline row layout used by the mobile sheet.
final bool dense;
@override
Widget build(BuildContext context) {
if (dense) return _buildDense(context);
return SwitchListTile(
dense: true,
title: Text(label),
subtitle: Text(subtitle, style: const TextStyle(fontSize: 11)),
value: value,
onChanged: onChanged,
);
}
Widget _buildDense(BuildContext context) {
final theme = Theme.of(context);
final disabled = onChanged == null;
return Padding(
padding: const EdgeInsets.symmetric(vertical: 2),
child: Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: theme.textTheme.bodyMedium?.copyWith(
color: disabled
? theme.colorScheme.onSurfaceVariant.withAlpha(120)
: null,
),
),
Text(
subtitle,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant.withAlpha(
disabled ? 80 : 160,
),
),
),
],
),
),
Switch(value: value, onChanged: onChanged),
],
),
);
}
}
@@ -0,0 +1,89 @@
import '../l10n/generated/app_localizations.dart';
import '../src/rust/api.dart' as rust;
import 'talk_power_warning.dart';
class VoiceStatusSummary {
const VoiceStatusSummary({
required this.modeLabel,
required this.line1,
required this.line2,
required this.statusText,
required this.micOn,
required this.talkPowerBlocked,
required this.muted,
});
final String modeLabel;
final String line1;
final String line2;
final String statusText;
final bool micOn;
final bool talkPowerBlocked;
final bool muted;
}
String voiceModeLabel(AppL10n l10n, rust.BridgeTransmitMode transmitMode) {
switch (transmitMode) {
case rust.BridgeTransmitMode.ptt:
return l10n.voiceModePtt;
case rust.BridgeTransmitMode.continuous:
return l10n.voiceModeContinuous;
case rust.BridgeTransmitMode.voiceActivity:
return l10n.voiceModeVoiceActivity;
}
}
VoiceStatusSummary voiceStatusSummary({
required AppL10n l10n,
required rust.BridgeTransmitMode transmitMode,
required int releaseTailMs,
required String pttBoundKeyLabel,
required bool isTouchOnly,
required bool inputMuted,
required bool outputMuted,
required bool pttActive,
int? talkPower,
int? neededTalkPower,
bool? talkPowerGranted,
}) {
final modeLabel = voiceModeLabel(l10n, transmitMode);
final talkPowerBlocked = isTalkPowerBlocked(
talkPower: talkPower,
neededTalkPower: neededTalkPower,
talkPowerGranted: talkPowerGranted,
);
final micOn = inputMuted || outputMuted || talkPowerBlocked
? false
: switch (transmitMode) {
rust.BridgeTransmitMode.continuous => true,
_ => pttActive,
};
final line1 = transmitMode == rust.BridgeTransmitMode.ptt
? isTouchOnly
? '$modeLabel \u00b7 ${l10n.voicePttHoldHint}'
: '$modeLabel \u00b7 ${pttBoundKeyLabel.isEmpty ? "\u2014" : pttBoundKeyLabel}'
: modeLabel;
final tailText = transmitMode == rust.BridgeTransmitMode.ptt
? '$releaseTailMs${l10n.voiceReleaseTailHint} ${l10n.voiceReleaseTailLabel.toLowerCase()}'
: null;
final statusText = talkPowerBlocked
? 'Insufficient permission'
: inputMuted
? '${l10n.voiceMicOff} (muted)'
: outputMuted
? 'Speaker muted'
: micOn
? l10n.voiceMicOn
: l10n.voiceMicOff;
return VoiceStatusSummary(
modeLabel: modeLabel,
line1: line1,
line2: tailText == null ? statusText : '$tailText \u00b7 $statusText',
statusText: statusText,
micOn: micOn,
talkPowerBlocked: talkPowerBlocked,
muted: inputMuted || outputMuted,
);
}
+32 -8
View File
@@ -169,6 +169,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "3.1.2"
cross_file:
dependency: transitive
description:
name: cross_file
sha256: "28bb3ae56f117b5aec029d702a90f57d285cd975c3c5c281eaca38dbc47c5937"
url: "https://pub.dev"
source: hosted
version: "0.3.5+2"
crypto:
dependency: transitive
description:
@@ -177,14 +185,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "3.0.7"
cupertino_icons:
dependency: "direct main"
description:
name: cupertino_icons
sha256: "41e005c33bd814be4d3096aff55b1908d419fde52ca656c8c47719ec745873cd"
url: "https://pub.dev"
source: hosted
version: "1.0.9"
dart_style:
dependency: transitive
description:
@@ -637,6 +637,22 @@ packages:
url: "https://pub.dev"
source: hosted
version: "0.28.0"
share_plus:
dependency: "direct main"
description:
name: share_plus
sha256: a857d8b1479250aff6b57a51b2c02d31ca05848d441817c43f1640c885c286c0
url: "https://pub.dev"
source: hosted
version: "13.1.0"
share_plus_platform_interface:
dependency: transitive
description:
name: share_plus_platform_interface
sha256: "7f7ae28cf400d13f811e297ff37742dba83b79e0a6f5dce14eec0248274e6ce9"
url: "https://pub.dev"
source: hosted
version: "7.1.0"
shared_preferences:
dependency: "direct main"
description:
@@ -850,6 +866,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "3.1.5"
uuid:
dependency: transitive
description:
name: uuid
sha256: "1fef9e8e11e2991bb773070d4656b7bd5d850967a2456cfc83cf47925ba79489"
url: "https://pub.dev"
source: hosted
version: "4.5.3"
vector_math:
dependency: transitive
description:
+1 -3
View File
@@ -34,9 +34,6 @@ dependencies:
sdk: flutter
intl: any
# The following adds the Cupertino Icons font to your application.
# Use with the CupertinoIcons class for iOS style icons.
cupertino_icons: ^1.0.8
flutter_rust_bridge: 2.12.0
freezed_annotation: ^3.1.0
connectivity_plus: ^7.1.1
@@ -80,6 +77,7 @@ dependencies:
flutter_foreground_task: ^9.2.2
url_launcher: ^6.3.2
shared_preferences: ^2.5.5
share_plus: ^13.1.0
dev_dependencies:
flutter_test:
@@ -0,0 +1,51 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:chanora_flutter/services/android_audio_output_devices.dart';
void main() {
test('parses Android audio output devices and skips non-maps', () {
final devices = parseAndroidAudioOutputDevices([
{
'id': 12,
'name': 'Speaker',
'type': 'speaker',
'isSelected': true,
'isAvailableForCommunication': true,
},
'bad',
{'id': null, 'name': null},
]);
expect(devices, hasLength(2));
expect(devices.first.id, '12');
expect(devices.first.name, 'Speaker');
expect(devices.first.type, 'speaker');
expect(devices.first.isSelected, isTrue);
expect(devices.first.isAvailableForCommunication, isTrue);
expect(devices.last.id, isEmpty);
expect(devices.last.name, isEmpty);
expect(devices.last.type, 'unknown');
});
test('finds selected Android audio output device', () {
final selected = selectedAndroidAudioOutputDevice([
const AndroidAudioOutputDevice(
id: '1',
name: 'Speaker',
type: 'speaker',
isSelected: false,
isAvailableForCommunication: true,
),
const AndroidAudioOutputDevice(
id: '2',
name: 'Headset',
type: 'wiredHeadset',
isSelected: true,
isAvailableForCommunication: true,
),
]);
expect(selected?.id, '2');
expect(selectedAndroidAudioOutputDevice(const []), isNull);
});
}
@@ -0,0 +1,22 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:chanora_flutter/services/app_bootstrap.dart';
void main() {
test('app version appends platform build number', () {
expect(
appVersionFromBuildNumber(
semverBaseline: 'v1.2.3-rc.4',
buildNumber: '56',
),
'v1.2.3-rc.4+56',
);
});
test('app version keeps baseline when build number is empty', () {
expect(
appVersionFromBuildNumber(semverBaseline: 'v1.2.3-rc.4', buildNumber: ''),
'v1.2.3-rc.4',
);
});
}
@@ -0,0 +1,25 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:chanora_flutter/services/audio_lifecycle_service.dart';
import 'package:chanora_flutter/src/rust/api.dart' as rust;
void main() {
test('parseBridgeAudioRoute maps platform route names', () {
expect(parseBridgeAudioRoute('Earpiece'), rust.BridgeAudioRoute.earpiece);
expect(parseBridgeAudioRoute('Speaker'), rust.BridgeAudioRoute.speaker);
expect(
parseBridgeAudioRoute('WiredHeadset'),
rust.BridgeAudioRoute.wiredHeadset,
);
expect(
parseBridgeAudioRoute('BluetoothHfp'),
rust.BridgeAudioRoute.bluetoothHfp,
);
expect(
parseBridgeAudioRoute('BluetoothA2dp'),
rust.BridgeAudioRoute.bluetoothA2Dp,
);
expect(parseBridgeAudioRoute('Unknown'), rust.BridgeAudioRoute.unknown);
expect(parseBridgeAudioRoute('Other'), rust.BridgeAudioRoute.unknown);
});
}
@@ -0,0 +1,69 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:chanora_flutter/l10n/generated/app_localizations.dart';
import 'package:chanora_flutter/services/channel_join_error_mapper.dart';
import 'package:chanora_flutter/src/rust/lib.dart' as rust_err;
void main() {
Future<AppL10n> loadEnglishL10n(WidgetTester tester) async {
late AppL10n l10n;
await tester.pumpWidget(
MaterialApp(
localizationsDelegates: AppL10n.localizationsDelegates,
supportedLocales: AppL10n.supportedLocales,
home: Builder(
builder: (context) {
l10n = AppL10n.of(context);
return const SizedBox.shrink();
},
),
),
);
return l10n;
}
testWidgets('maps known TS3 server rejection codes', (tester) async {
final l10n = await loadEnglishL10n(tester);
expect(
channelJoinErrorMessage(
l10n,
const rust_err.BridgeError.serverRejected(
code: 0x030d,
message: 'invalid password',
),
),
l10n.channelJoinFailedPassword,
);
expect(
channelJoinErrorMessage(
l10n,
const rust_err.BridgeError.serverRejected(
code: 0x0a08,
message: 'insufficient permissions',
),
),
l10n.channelJoinFailedPermission,
);
});
testWidgets('falls back for generic and unknown errors', (tester) async {
final l10n = await loadEnglishL10n(tester);
expect(
channelJoinErrorMessage(
l10n,
const rust_err.BridgeError.serverRejected(
code: 0xffff,
message: 'custom server error',
),
),
l10n.channelJoinFailedGeneric('custom server error'),
);
expect(
channelJoinErrorMessage(l10n, 'plain error'),
l10n.channelJoinFailedGeneric('plain error'),
);
});
}
@@ -0,0 +1,127 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:chanora_flutter/services/channel_spacer.dart';
void main() {
test('classifies only valid bracketed spacer tags as spacer channels', () {
expect(isSpacerChannelName('[Spacer0]Lobby'), isTrue);
expect(isSpacerChannelName('[cSpacer]Lobby'), isTrue);
expect(isSpacerChannelName('[*spacer0]#=='), isTrue);
expect(isSpacerChannelName('Lobby spacer room'), isFalse);
expect(isSpacerChannelName('prefix [Spacer0]Lobby'), isFalse);
expect(isSpacerChannelName('[xSpacer0]Lobby'), isFalse);
});
test('parses valid spacer names without requiring numeric suffixes', () {
final parsed = parseSpacerChannelName('[cSpAcErabc-01] Lobby ');
expect(parsed.isSpacer, isTrue);
expect(parsed.isValid, isTrue);
expect(parsed.alignment, SpacerAlignment.center);
expect(parsed.isRepeating, isFalse);
expect(parsed.uniqueSuffix, 'abc-01');
expect(parsed.text, ' Lobby ');
expect(parsed.specialType, isNull);
expect(parsed.isBlankSpacer, isFalse);
expect(parsed.reason, isNull);
});
test('parses all special separator line values', () {
expect(
parseSpacerChannelName('[Spacer0]___').specialType,
SpacerSpecialType.solidLine,
);
expect(
parseSpacerChannelName('[Spacer0]---').specialType,
SpacerSpecialType.dashLine,
);
expect(
parseSpacerChannelName('[Spacer0]...').specialType,
SpacerSpecialType.dotLine,
);
expect(
parseSpacerChannelName('[Spacer0]-.-').specialType,
SpacerSpecialType.dashDotLine,
);
expect(
parseSpacerChannelName('[Spacer0]-..').specialType,
SpacerSpecialType.dashDotDotLine,
);
});
test('parses repeating spacer text exactly', () {
final parsed = parseSpacerChannelName('[*spacer0]#==');
expect(parsed.isSpacer, isTrue);
expect(parsed.isValid, isTrue);
expect(parsed.isRepeating, isTrue);
expect(parsed.uniqueSuffix, '0');
expect(parsed.text, '#==');
expect(channelSpacerLabel('[*spacer0]#=='), startsWith('#==#=='));
});
test('parses known blank-looking right spacer', () {
final parsed = parseSpacerChannelName('[rSpacer0].');
expect(parsed.isSpacer, isTrue);
expect(parsed.alignment, SpacerAlignment.right);
expect(parsed.text, '.');
expect(parsed.isBlankSpacer, isTrue);
expect(channelSpacerLabel('[rSpacer0].'), isEmpty);
});
test('reports malformed spacer-like names', () {
final missingBracket = parseSpacerChannelName('[cSpacer0');
expect(missingBracket.isSpacer, isFalse);
expect(missingBracket.isValid, isFalse);
expect(missingBracket.reason, 'missing closing bracket');
final invalidFlag = parseSpacerChannelName('[xSpacer0]Lobby');
expect(invalidFlag.isSpacer, isFalse);
expect(invalidFlag.isValid, isFalse);
expect(invalidFlag.reason, 'invalid spacer tag');
final duplicateAlignment = parseSpacerChannelName('[lcSpacer0]Lobby');
expect(duplicateAlignment.isSpacer, isFalse);
expect(duplicateAlignment.isValid, isFalse);
expect(duplicateAlignment.reason, 'multiple alignment flags');
});
test('reports ordinary channel names as non-spacers', () {
final parsed = parseSpacerChannelName('A channel with spacer in its name');
expect(parsed.isSpacer, isFalse);
expect(parsed.isValid, isFalse);
expect(parsed.reason, 'not a spacer channel name');
expect(
channelSpacerLabel('A channel with spacer in its name'),
'A channel with spacer in its name',
);
});
test('formats spacer names deterministically', () {
final formatted = formatSpacerChannelName(
const SpacerChannelNameFormatOptions(
alignment: SpacerAlignment.right,
isRepeating: true,
uniqueSuffix: 'abc',
text: '#==',
),
);
expect(formatted, '[*rSpacerabc]#==');
final parsed = parseSpacerChannelName(formatted);
expect(parsed.isSpacer, isTrue);
expect(parsed.alignment, SpacerAlignment.right);
expect(parsed.isRepeating, isTrue);
expect(parsed.uniqueSuffix, 'abc');
expect(parsed.text, '#==');
});
test('formats existing display labels through the utility', () {
expect(channelSpacerLabel('[cSpacer]Lobby'), 'Lobby');
expect(channelSpacerLabel('[Spacer0]___'), '────────');
expect(channelSpacerLabel('Lobby'), 'Lobby');
});
}
@@ -0,0 +1,81 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:chanora_flutter/l10n/generated/app_localizations_en.dart';
import 'package:chanora_flutter/services/connection_phase_state.dart';
void main() {
final l10n = AppL10nEn();
test('connection phases expose shared predicates', () {
expect(ConnectionPhase.idle.isServerReachable, isFalse);
expect(ConnectionPhase.connecting.isServerReachable, isFalse);
expect(ConnectionPhase.synchronizing.isServerReachable, isTrue);
expect(ConnectionPhase.connected.isServerReachable, isTrue);
expect(ConnectionPhase.reconnecting.isServerReachable, isTrue);
expect(ConnectionPhase.disconnected.isServerReachable, isFalse);
expect(ConnectionPhase.synchronizing.canOpenChat, isTrue);
expect(ConnectionPhase.connected.canOpenChat, isTrue);
expect(ConnectionPhase.reconnecting.canOpenChat, isFalse);
expect(ConnectionPhase.connected.canDisconnect, isTrue);
});
test('connection status text maps all phases', () {
expect(
connectionStatusText(phase: ConnectionPhase.idle, l10n: l10n),
l10n.statusIdle,
);
expect(
connectionStatusText(phase: ConnectionPhase.connecting, l10n: l10n),
l10n.statusConnecting,
);
expect(
connectionStatusText(phase: ConnectionPhase.synchronizing, l10n: l10n),
'Synchronizing...',
);
expect(
connectionStatusText(
phase: ConnectionPhase.connected,
l10n: l10n,
serverName: 'Server',
),
'Connected to Server',
);
expect(
connectionStatusText(
phase: ConnectionPhase.reconnecting,
l10n: l10n,
reconnectAttempt: 2,
reconnectDelay: 5,
),
l10n.statusReconnecting(2, 5),
);
expect(
connectionStatusText(
phase: ConnectionPhase.reconnecting,
l10n: l10n,
lostReason: 'network',
),
'Connection lost: network',
);
expect(
connectionStatusText(phase: ConnectionPhase.disconnected, l10n: l10n),
l10n.statusIdle,
);
});
test('connection phases map to token icons', () {
final scheme = ColorScheme.fromSeed(seedColor: Colors.indigo);
expect(ConnectionPhase.idle.tokens(scheme).icon, Icons.cloud_off);
expect(ConnectionPhase.connecting.tokens(scheme).icon, Icons.sync);
expect(
ConnectionPhase.synchronizing.tokens(scheme).icon,
Icons.hourglass_top,
);
expect(ConnectionPhase.connected.tokens(scheme).icon, Icons.cloud_done);
expect(ConnectionPhase.reconnecting.tokens(scheme).icon, Icons.restart_alt);
expect(ConnectionPhase.disconnected.tokens(scheme).icon, Icons.cloud_off);
});
}
@@ -0,0 +1,152 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:chanora_flutter/services/snapshot_state_mapper.dart';
import 'package:chanora_flutter/src/rust/api.dart' as rust;
void main() {
rust.BridgeChannel channel({int neededTalkPower = 0}) {
return rust.BridgeChannel(
id: BigInt.one,
parent: BigInt.zero,
name: 'Lobby',
order: 0,
hasPassword: false,
neededTalkPower: neededTalkPower,
);
}
rust.BridgeClient client({
BigInt? id,
BigInt? channelId,
int talkPower = 0,
bool talkPowerGranted = false,
bool inputMuted = false,
bool outputMuted = false,
}) {
return rust.BridgeClient(
id: id ?? BigInt.from(7),
channel: channelId ?? BigInt.one,
name: 'Me',
inputMuted: inputMuted,
outputMuted: outputMuted,
isSpeaking: false,
isServerQuery: false,
talkPower: talkPower,
talkPowerGranted: talkPowerGranted,
);
}
rust.BridgeSnapshot snapshot({
required List<rust.BridgeChannel> channels,
required List<rust.BridgeClient> clients,
BigInt? ownClientId,
}) {
return rust.BridgeSnapshot(
serverName: 'Server',
welcomeMessage: '',
platform: '',
version: '',
channels: channels,
clients: clients,
ownClientId: ownClientId ?? BigInt.from(7),
);
}
test('extracts own channel and mute state', () {
final state = ownClientSnapshotState(
snapshot(
channels: [channel()],
clients: [client(inputMuted: true, outputMuted: true)],
),
);
expect(state?.channelId, BigInt.one);
expect(state?.inputMuted, isTrue);
expect(state?.outputMuted, isTrue);
expect(state?.talkPowerOk, isTrue);
expect(state?.talkPower, 0);
expect(state?.talkPowerGranted, isFalse);
expect(state?.neededTalkPower, 0);
});
test('detects insufficient talk power', () {
final state = ownClientSnapshotState(
snapshot(
channels: [channel(neededTalkPower: 20)],
clients: [client(talkPower: 10)],
),
);
expect(state?.talkPowerOk, isFalse);
expect(state?.talkPower, 10);
expect(state?.neededTalkPower, 20);
});
test('accepts granted or sufficient talk power', () {
final sufficient = ownClientSnapshotState(
snapshot(
channels: [channel(neededTalkPower: 20)],
clients: [client(talkPower: 20)],
),
);
final granted = ownClientSnapshotState(
snapshot(
channels: [channel(neededTalkPower: 20)],
clients: [client(talkPowerGranted: true)],
),
);
expect(sufficient?.talkPowerOk, isTrue);
expect(granted?.talkPowerOk, isTrue);
});
test('returns null when own client is absent', () {
final state = ownClientSnapshotState(
snapshot(
channels: [channel()],
clients: [client(id: BigInt.from(9))],
),
);
expect(state, isNull);
});
test('resolves channel name by id', () {
final snap = snapshot(
channels: [
channel(),
rust.BridgeChannel(
id: BigInt.from(2),
parent: BigInt.zero,
name: 'Raid Room',
order: 1,
hasPassword: false,
neededTalkPower: 0,
),
],
clients: [client()],
);
expect(snapshotChannelName(snap, BigInt.from(2)), 'Raid Room');
});
test('returns empty channel name when snapshot or channel is absent', () {
final snap = snapshot(channels: [channel()], clients: [client()]);
expect(snapshotChannelName(null, BigInt.one), isEmpty);
expect(snapshotChannelName(snap, null), isEmpty);
expect(snapshotChannelName(snap, BigInt.from(99)), isEmpty);
});
test('resolves needed talk power by channel id', () {
final snap = snapshot(
channels: [channel(neededTalkPower: 30)],
clients: [client()],
);
expect(snapshotNeededTalkPower(snap, BigInt.one), 30);
expect(snapshotNeededTalkPower(null, BigInt.one), isNull);
expect(snapshotNeededTalkPower(snap, null), isNull);
expect(snapshotNeededTalkPower(snap, BigInt.from(99)), isNull);
});
}
@@ -0,0 +1,44 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:chanora_flutter/services/ts3_server_link.dart';
void main() {
test('parses encoded add-bookmark TeamSpeak link', () {
final link = parseTs3ServerLink(
'ts3server://teamspeak.app%3Faddbookmark%3DVigorous%20Pro/',
);
expect(link, isNotNull);
expect(link!.host, 'teamspeak.app');
expect(link.hostWithPort, 'teamspeak.app');
expect(link.addBookmark, 'Vigorous Pro');
});
test('parses full TeamSpeak server link parameters', () {
final link = parseTs3ServerLink(
'ts3server://ts3.hoster.com?port=9987&nickname=UserNickname'
'&password=serverPassword&channel=MyDefaultChannel&cid=123'
'&channelpassword=defaultChannelPassword&token=TokenKey'
'&addbookmark=MyBookMarkLabel',
);
expect(link, isNotNull);
expect(link!.host, 'ts3.hoster.com');
expect(link.hostWithPort, 'ts3.hoster.com:9987');
expect(link.port, 9987);
expect(link.nickname, 'UserNickname');
expect(link.password, 'serverPassword');
expect(link.channel, 'MyDefaultChannel');
expect(link.cid, '123');
expect(link.channelPassword, 'defaultChannelPassword');
expect(link.token, 'TokenKey');
expect(link.addBookmark, 'MyBookMarkLabel');
});
test('keeps explicit host port without duplicating port query', () {
final link = parseTs3ServerLink('ts3server://ts3.hoster.com:9987');
expect(link, isNotNull);
expect(link!.hostWithPort, 'ts3.hoster.com:9987');
});
}
@@ -16,7 +16,6 @@ void main() {
expect(settings.host, isEmpty);
expect(settings.nickname, isEmpty);
expect(settings.showPokeDialogs, isTrue);
});
test('saves and loads host and nickname independently', () async {
@@ -33,21 +32,6 @@ void main() {
expect(settings.nickname, 'Chanora');
});
test('saves poke dialog preference independently', () async {
await service.saveSettings(showPokeDialogs: false);
var settings = await service.loadSettings();
expect(settings.showPokeDialogs, isFalse);
expect(settings.host, isEmpty);
expect(settings.nickname, isEmpty);
await service.saveSettings(host: 'example.com');
settings = await service.loadSettings();
expect(settings.showPokeDialogs, isFalse);
expect(settings.host, 'example.com');
});
test('tracks permission explanation flag', () async {
expect(await service.hasExplainedPermissions(), isFalse);
@@ -190,6 +190,34 @@ void main() {
expect(states, [true, false]);
});
testWidgets('on-screen PTT can release from pan gestures', (tester) async {
final states = <bool>[];
await tester.pumpWidget(
MaterialApp(
localizationsDelegates: AppL10n.localizationsDelegates,
supportedLocales: AppL10n.supportedLocales,
home: Scaffold(
body: VoicePttButton(
active: false,
listenForPan: true,
onHeldChanged: states.add,
),
),
),
);
final center = tester.getCenter(find.byType(VoicePttButton));
final gesture = await tester.startGesture(center);
await tester.pump();
await gesture.moveBy(const Offset(0, 24));
await tester.pump();
await gesture.up();
await tester.pump();
expect(states, [true, false]);
});
testWidgets('iOS permission service maps channel states and settings', (
tester,
) async {
@@ -0,0 +1,32 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:chanora_flutter/src/rust/api.dart' as rust;
import 'package:chanora_flutter/widgets/audio_device_list_tile.dart';
void main() {
testWidgets(
'audio device tile renders loading state before devices resolve',
(tester) async {
final pendingDevices = Completer<rust.BridgeAudioDeviceList>();
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: AudioDeviceListTile(
label: 'Input',
kind: AudioDeviceKind.input,
loadDevices: () => pendingDevices.future,
),
),
),
);
expect(find.text('Input'), findsOneWidget);
expect(find.text('Loading...'), findsOneWidget);
expect(find.byType(CircularProgressIndicator), findsOneWidget);
},
);
}
@@ -0,0 +1,81 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:chanora_flutter/src/rust/api.dart' as rust;
import 'package:chanora_flutter/widgets/audio_processing_config_state.dart';
void main() {
const baseConfig = rust.BridgeAudioProcessingConfig(
route: rust.BridgeAudioRoute.unknown,
iosMode: rust.BridgeIosVoiceProcessingMode.platformVoiceProcessing,
processingBackend: rust.BridgeAudioBackend.platformVoiceProcessing,
vadBackend: rust.BridgeVadBackend.disabled,
aec: rust.BridgeEffectOwner.platform,
ns: rust.BridgeEffectOwner.off,
agc: rust.BridgeEffectOwner.webrtcApm,
hpfEnabled: true,
limiterEnabled: false,
vadHangoverMs: 500,
vadPreRollMs: 160,
vadMinTxMs: 200,
debugWavDumpEnabled: true,
);
test('normalizes hidden disabled VAD backend for UI state', () {
final state = AudioProcessingConfigState.fromConfig(baseConfig);
expect(state.vadBackend, rust.BridgeVadBackend.webrtcVad);
expect(state.preferHardware, isTrue);
expect(state.nsEnabled, isFalse);
expect(state.aecEnabled, isTrue);
expect(state.agcEnabled, isTrue);
});
test('default config uses platform processing and Silero VAD', () {
expect(
defaultAudioProcessingConfig.processingBackend,
rust.BridgeAudioBackend.platformVoiceProcessing,
);
expect(
defaultAudioProcessingConfig.vadBackend,
rust.BridgeVadBackend.sileroOnnx,
);
expect(defaultAudioProcessingConfig.aec, rust.BridgeEffectOwner.platform);
expect(defaultAudioProcessingConfig.ns, rust.BridgeEffectOwner.platform);
expect(defaultAudioProcessingConfig.agc, rust.BridgeEffectOwner.platform);
});
test('builds Android hardware config consistently', () {
final state = AudioProcessingConfigState.fromConfig(baseConfig)
..preferHardware = true
..nsEnabled = true
..aecEnabled = false
..agcEnabled = true;
final config = state.buildConfig(base: baseConfig, isAndroid: true);
expect(
config.processingBackend,
rust.BridgeAudioBackend.platformVoiceProcessing,
);
expect(config.vadBackend, rust.BridgeVadBackend.webrtcVad);
expect(config.aec, rust.BridgeEffectOwner.off);
expect(config.ns, rust.BridgeEffectOwner.platform);
expect(config.agc, rust.BridgeEffectOwner.platform);
expect(config.debugWavDumpEnabled, isTrue);
});
test('builds Sonora config with WebRTC-owned enabled effects', () {
final state = AudioProcessingConfigState.fromConfig(baseConfig)
..iosMode = rust.BridgeIosVoiceProcessingMode.sonoraExperimental
..nsEnabled = true
..aecEnabled = false
..agcEnabled = true;
final config = state.buildConfig(base: baseConfig, isAndroid: false);
expect(config.processingBackend, rust.BridgeAudioBackend.webrtcApm);
expect(config.aec, rust.BridgeEffectOwner.off);
expect(config.ns, rust.BridgeEffectOwner.webrtcApm);
expect(config.agc, rust.BridgeEffectOwner.webrtcApm);
});
}
@@ -0,0 +1,53 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:chanora_flutter/services/link_trust_service.dart';
import 'package:chanora_flutter/services/ts3_server_link.dart';
import 'package:chanora_flutter/widgets/bbcode_text.dart';
void main() {
setUp(() {
SharedPreferences.setMockInitialValues({});
});
testWidgets('renders links without underline decoration', (tester) async {
await tester.pumpWidget(
MaterialApp(
home: BbCodeText(
'https://example.com',
linkTrust: LinkTrustService.instance,
),
),
);
final linkText = tester.widget<Text>(find.text('https://example.com'));
expect(linkText.style?.color, Colors.blue);
expect(linkText.style?.decoration, isNull);
});
testWidgets('handles TeamSpeak server links without browser launch', (
tester,
) async {
Ts3ServerLink? tapped;
await tester.pumpWidget(
MaterialApp(
home: BbCodeText(
'ts3server://teamspeak.app%3Faddbookmark%3DVigorous%20Pro/',
linkTrust: LinkTrustService.instance,
onTs3ServerLink: (link) async => tapped = link,
),
),
);
await tester.tap(
find.text('ts3server://teamspeak.app%3Faddbookmark%3DVigorous%20Pro/'),
);
await tester.pump();
expect(tapped, isNotNull);
expect(tapped!.host, 'teamspeak.app');
expect(tapped!.addBookmark, 'Vigorous Pro');
});
}
@@ -331,25 +331,98 @@ void main() {
);
});
testWidgets('chat hub does not expose pokes as a chat tab', (tester) async {
testWidgets('chat sidebar keeps server and channel fixed above privates', (
tester,
) async {
await tester.pumpWidget(
MaterialApp(
home: ChatPage(
messages: [
entry(rust.BridgeMessageTarget.poke(BigInt.from(2))),
ChatEntry(
senderId: BigInt.from(2),
senderName: 'Alpha',
message: 'Private',
target: rust.BridgeMessageTarget.client(BigInt.from(2)),
),
entry(const rust.BridgeMessageTarget.server()),
],
snapshot: snapshot(channels: const [], clients: const []),
snapshot: snapshot(
channels: const [],
clients: [
client(id: BigInt.from(2), name: 'Alpha', channelId: BigInt.zero),
],
),
),
),
);
expect(find.text('Direct Messages'), findsOneWidget);
expect(find.text('Server Activity'), findsOneWidget);
expect(find.text('Server'), findsWidgets);
expect(find.text('Channel'), findsOneWidget);
expect(find.text('Alpha'), findsWidgets);
expect(find.text('Pokes'), findsNothing);
expect(find.text('No pokes'), findsNothing);
final serverTop = tester.getTopLeft(find.text('Server').first).dy;
final channelTop = tester.getTopLeft(find.text('Channel')).dy;
final privateTop = tester.getTopLeft(find.text('Alpha').first).dy;
expect(serverTop, lessThan(channelTop));
expect(channelTop, lessThan(privateTop));
});
testWidgets(
'plus opens user picker and close removes selected private chat',
(tester) async {
await tester.pumpWidget(
MaterialApp(
home: ChatPage(
messages: [
ChatEntry(
senderId: BigInt.from(2),
senderName: 'Alpha',
message: 'Private',
target: rust.BridgeMessageTarget.client(BigInt.from(2)),
),
],
snapshot: snapshot(
channels: const [],
clients: [
client(
id: BigInt.from(2),
name: 'Alpha',
channelId: BigInt.zero,
),
client(
id: BigInt.from(3),
name: 'Bravo',
channelId: BigInt.zero,
),
],
),
initialTarget: rust.BridgeMessageTarget.client(BigInt.from(2)),
initialClientName: 'Alpha',
),
),
);
expect(find.text('Alpha'), findsWidgets);
expect(find.byTooltip('Close chat'), findsOneWidget);
await tester.tap(find.byTooltip('Close chat'));
await tester.pump();
expect(find.text('Alpha'), findsNothing);
expect(find.text('Server Activity'), findsOneWidget);
await tester.tap(find.byTooltip('New private chat'));
await tester.pumpAndSettle();
expect(find.text('Search clients...'), findsOneWidget);
expect(find.text('Bravo'), findsOneWidget);
},
);
testWidgets('channel chat displays localized poke history in English', (
tester,
) async {
@@ -0,0 +1,437 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:chanora_flutter/l10n/generated/app_localizations.dart';
import 'package:chanora_flutter/src/rust/api.dart' as rust;
import 'package:chanora_flutter/widgets/snapshot_view.dart';
void main() {
rust.BridgeChannel channel({
required int id,
int parent = 0,
required String name,
int order = 0,
bool hasPassword = false,
int neededTalkPower = 0,
}) {
return rust.BridgeChannel(
id: BigInt.from(id),
parent: BigInt.from(parent),
name: name,
order: order,
hasPassword: hasPassword,
neededTalkPower: neededTalkPower,
);
}
rust.BridgeClient client({
required int id,
required int channelId,
required String name,
bool speaking = false,
int talkPower = 0,
bool talkPowerGranted = false,
}) {
return rust.BridgeClient(
id: BigInt.from(id),
channel: BigInt.from(channelId),
name: name,
inputMuted: false,
outputMuted: false,
isSpeaking: speaking,
isServerQuery: false,
talkPower: talkPower,
talkPowerGranted: talkPowerGranted,
);
}
Widget snapshotHarness({
required List<rust.BridgeChannel> channels,
required List<rust.BridgeClient> clients,
BigInt? ownClientId,
BigInt? currentVoiceChannelId,
rust.BridgeAudioStats? audioStats,
}) {
return MaterialApp(
localizationsDelegates: AppL10n.localizationsDelegates,
supportedLocales: AppL10n.supportedLocales,
home: Scaffold(
body: SnapshotView(
snapshot: rust.BridgeSnapshot(
serverName: 'Server',
welcomeMessage: '',
platform: '',
version: '',
channels: channels,
clients: clients,
ownClientId: ownClientId ?? BigInt.from(100),
),
audioStats: audioStats,
currentVoiceChannelId: currentVoiceChannelId,
pendingVoiceChannelId: null,
localInputMuted: false,
localOutputMuted: false,
hasJoinPending: false,
canJoinVoiceChannel: true,
onJoinChannel: (_) {},
onJoinChannelWithPassword: (_) {},
),
),
);
}
testWidgets('renders channel tree with users and subchannels expanded', (
tester,
) async {
await tester.pumpWidget(
snapshotHarness(
channels: [
channel(id: 1, name: 'Default Channel'),
channel(id: 2, parent: 1, name: 'Default Sub Channel'),
channel(id: 3, name: 'Quiet Zone'),
],
clients: [
client(id: 100, channelId: 1, name: 'Alice'),
client(id: 101, channelId: 2, name: 'Bob'),
client(id: 102, channelId: 3, name: 'Carol'),
],
ownClientId: BigInt.from(100),
currentVoiceChannelId: BigInt.from(1),
),
);
await tester.pumpAndSettle();
expect(find.text('Default Channel'), findsOneWidget);
expect(find.text('Alice'), findsOneWidget);
expect(find.text('Default Sub Channel'), findsOneWidget);
expect(find.text('Bob'), findsOneWidget);
expect(find.text('Quiet Zone'), findsOneWidget);
expect(find.text('Carol'), findsOneWidget);
expect(
tester.getTopLeft(find.text('Alice')).dy,
greaterThan(tester.getTopLeft(find.text('Default Channel')).dy),
);
expect(
tester.getTopLeft(find.text('Default Sub Channel')).dy,
greaterThan(tester.getTopLeft(find.text('Alice')).dy),
);
expect(
tester.getTopLeft(find.text('Bob')).dy,
greaterThan(tester.getTopLeft(find.text('Default Sub Channel')).dy),
);
});
testWidgets('collapsing a channel hides users and child channels', (
tester,
) async {
await tester.pumpWidget(
snapshotHarness(
channels: [
channel(id: 1, name: 'Default Channel'),
channel(id: 2, parent: 1, name: 'Default Sub Channel'),
channel(id: 3, name: 'Quiet Zone'),
],
clients: [
client(id: 100, channelId: 1, name: 'Alice'),
client(id: 101, channelId: 2, name: 'Bob'),
client(id: 102, channelId: 3, name: 'Carol'),
],
),
);
await tester.pumpAndSettle();
await tester.tap(find.byIcon(Icons.expand_more).first);
await tester.pumpAndSettle();
expect(find.text('Default Channel'), findsOneWidget);
expect(find.text('Alice'), findsNothing);
expect(find.text('Default Sub Channel'), findsNothing);
expect(find.text('Bob'), findsNothing);
expect(find.text('Quiet Zone'), findsOneWidget);
expect(find.text('Carol'), findsOneWidget);
await tester.tap(find.byIcon(Icons.chevron_right).first);
await tester.pumpAndSettle();
expect(find.text('Alice'), findsOneWidget);
expect(find.text('Default Sub Channel'), findsOneWidget);
expect(find.text('Bob'), findsOneWidget);
});
testWidgets('channel tree uses compact fixed columns', (tester) async {
await tester.pumpWidget(
snapshotHarness(
channels: [
channel(id: 1, name: 'Default Channel'),
channel(id: 2, parent: 1, name: 'Default Sub Channel'),
channel(id: 3, name: 'Empty Channel'),
],
clients: [
client(id: 100, channelId: 1, name: 'Alice'),
client(id: 101, channelId: 2, name: 'Bob'),
],
),
);
await tester.pumpAndSettle();
final parentX = tester.getTopLeft(find.text('Default Channel')).dx;
final parentUserX = tester.getTopLeft(find.text('Alice')).dx;
final childX = tester.getTopLeft(find.text('Default Sub Channel')).dx;
final childUserX = tester.getTopLeft(find.text('Bob')).dx;
expect(find.text('Channels'), findsNothing);
expect(find.byIcon(Icons.tag), findsNWidgets(3));
expect(parentX, inInclusiveRange(58, 66));
expect(parentUserX - parentX, inInclusiveRange(22, 28));
expect(childX - parentX, inInclusiveRange(10, 14));
expect(childUserX - childX, inInclusiveRange(22, 28));
});
testWidgets('password channel shows lock at row end', (tester) async {
await tester.pumpWidget(
snapshotHarness(
channels: [channel(id: 1, name: 'Locked Channel', hasPassword: true)],
clients: const [],
),
);
await tester.pumpAndSettle();
final rowRight = tester.getTopRight(find.text('Locked Channel')).dx;
final lockLeft = tester.getTopLeft(find.byIcon(Icons.lock_outline)).dx;
expect(find.byIcon(Icons.tag), findsOneWidget);
expect(lockLeft, greaterThan(rowRight));
});
testWidgets('current user row does not show speaking background while idle', (
tester,
) async {
await tester.pumpWidget(
snapshotHarness(
channels: [channel(id: 1, name: 'Default Channel')],
clients: [client(id: 100, channelId: 1, name: 'Alice')],
ownClientId: BigInt.from(100),
currentVoiceChannelId: BigInt.from(1),
),
);
await tester.pumpAndSettle();
expect(find.text('Alice'), findsOneWidget);
expect(find.byIcon(Icons.tag), findsOneWidget);
expect(find.byIcon(Icons.mic_none), findsOneWidget);
expect(find.byType(ListTile), findsOneWidget);
final userHighlight = tester.widget<AnimatedContainer>(
find.ancestor(
of: find.text('Alice'),
matching: find.byType(AnimatedContainer),
),
);
expect(userHighlight.decoration, isNull);
});
testWidgets(
'local voice activity does not light speaking state when blocked',
(tester) async {
await tester.pumpWidget(
snapshotHarness(
channels: [
channel(id: 1, name: 'Default Channel', neededTalkPower: 10),
],
clients: [client(id: 100, channelId: 1, name: 'Alice', talkPower: 0)],
ownClientId: BigInt.from(100),
currentVoiceChannelId: BigInt.from(1),
audioStats: const rust.BridgeAudioStats(
framesSent: 1,
framesReceived: 0,
pttActive: true,
),
),
);
await tester.pumpAndSettle();
final userText = tester.widget<Text>(find.text('Alice'));
expect(find.byIcon(Icons.volume_off), findsOneWidget);
expect(find.byIcon(Icons.mic), findsNothing);
expect(userText.style?.fontWeight, isNot(FontWeight.w600));
},
);
testWidgets('spacer channels render as layout rows and keep channel taps', (
tester,
) async {
final spacerChannel = rust.BridgeChannel(
id: BigInt.from(42),
parent: BigInt.from(7),
name: '[cSpacerabc]Spacer Heading',
order: 99,
hasPassword: false,
neededTalkPower: 12,
);
final normalChannel = rust.BridgeChannel(
id: BigInt.from(43),
parent: BigInt.zero,
name: 'Normal Room',
order: 100,
hasPassword: false,
neededTalkPower: 0,
);
rust.BridgeChannel? tapped;
await tester.pumpWidget(
MaterialApp(
localizationsDelegates: AppL10n.localizationsDelegates,
supportedLocales: AppL10n.supportedLocales,
home: Scaffold(
body: SnapshotView(
snapshot: rust.BridgeSnapshot(
serverName: 'Server',
welcomeMessage: '',
platform: '',
version: '',
channels: [spacerChannel, normalChannel],
clients: const [],
ownClientId: BigInt.one,
),
audioStats: null,
currentVoiceChannelId: null,
pendingVoiceChannelId: null,
localInputMuted: false,
localOutputMuted: false,
hasJoinPending: false,
canJoinVoiceChannel: true,
onJoinChannel: (channel) => tapped = channel,
onJoinChannelWithPassword: (_) {},
),
),
),
);
await tester.pumpAndSettle();
expect(find.text('Spacer Heading'), findsOneWidget);
expect(find.text('[cSpacerabc]Spacer Heading'), findsNothing);
expect(find.byIcon(Icons.tag), findsOneWidget);
final spacerText = tester.widget<Text>(find.text('Spacer Heading'));
expect(spacerText.textAlign, TextAlign.center);
await tester.tap(find.text('Spacer Heading'));
await tester.pump();
expect(tapped, isNotNull);
expect(tapped!.id, BigInt.from(42));
expect(tapped!.parent, BigInt.from(7));
expect(tapped!.name, '[cSpacerabc]Spacer Heading');
expect(tapped!.order, 99);
expect(tapped!.hasPassword, isFalse);
expect(tapped!.neededTalkPower, 12);
});
testWidgets('separator spacers render as line painters without raw text', (
tester,
) async {
await tester.pumpWidget(
MaterialApp(
localizationsDelegates: AppL10n.localizationsDelegates,
supportedLocales: AppL10n.supportedLocales,
home: Scaffold(
body: SnapshotView(
snapshot: rust.BridgeSnapshot(
serverName: 'Server',
welcomeMessage: '',
platform: '',
version: '',
channels: [
rust.BridgeChannel(
id: BigInt.from(1),
parent: BigInt.zero,
name: '[spacer]---',
order: 1,
hasPassword: false,
neededTalkPower: 0,
),
],
clients: const [],
ownClientId: BigInt.one,
),
audioStats: null,
currentVoiceChannelId: null,
pendingVoiceChannelId: null,
localInputMuted: false,
localOutputMuted: false,
hasJoinPending: false,
canJoinVoiceChannel: true,
onJoinChannel: (_) {},
onJoinChannelWithPassword: (_) {},
),
),
),
);
await tester.pumpAndSettle();
expect(find.text('[spacer]---'), findsNothing);
expect(find.text('---'), findsNothing);
expect(find.byType(CustomPaint), findsWidgets);
expect(find.byIcon(Icons.tag), findsNothing);
});
testWidgets('repeating and blank spacers hide raw tags', (tester) async {
await tester.pumpWidget(
MaterialApp(
localizationsDelegates: AppL10n.localizationsDelegates,
supportedLocales: AppL10n.supportedLocales,
home: Scaffold(
body: SnapshotView(
snapshot: rust.BridgeSnapshot(
serverName: 'Server',
welcomeMessage: '',
platform: '',
version: '',
channels: [
rust.BridgeChannel(
id: BigInt.from(1),
parent: BigInt.zero,
name: '[*spacer0]#==',
order: 1,
hasPassword: false,
neededTalkPower: 0,
),
rust.BridgeChannel(
id: BigInt.from(2),
parent: BigInt.zero,
name: '[rSpacer0].',
order: 2,
hasPassword: false,
neededTalkPower: 0,
),
],
clients: const [],
ownClientId: BigInt.one,
),
audioStats: null,
currentVoiceChannelId: null,
pendingVoiceChannelId: null,
localInputMuted: false,
localOutputMuted: false,
hasJoinPending: false,
canJoinVoiceChannel: true,
onJoinChannel: (_) {},
onJoinChannelWithPassword: (_) {},
),
),
),
);
await tester.pumpAndSettle();
expect(find.text('[*spacer0]#=='), findsNothing);
expect(find.text('[rSpacer0].'), findsNothing);
expect(find.text('.'), findsNothing);
expect(find.textContaining('#==#=='), findsOneWidget);
expect(find.byIcon(Icons.tag), findsNothing);
});
}
@@ -0,0 +1,73 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:chanora_flutter/widgets/talk_power_warning.dart';
void main() {
test('talk power policy only blocks insufficient ungranted clients', () {
expect(
isTalkPowerBlocked(
talkPower: 5,
neededTalkPower: 10,
talkPowerGranted: false,
),
isTrue,
);
expect(
isTalkPowerBlocked(
talkPower: 10,
neededTalkPower: 10,
talkPowerGranted: false,
),
isFalse,
);
expect(
isTalkPowerBlocked(
talkPower: 5,
neededTalkPower: 10,
talkPowerGranted: true,
),
isFalse,
);
expect(
isTalkPowerBlocked(
talkPower: null,
neededTalkPower: 10,
talkPowerGranted: false,
),
isFalse,
);
});
testWidgets('talk power warning hides when not blocked', (tester) async {
await tester.pumpWidget(
const MaterialApp(
home: TalkPowerWarning(
talkPower: 10,
neededTalkPower: 10,
talkPowerGranted: false,
),
),
);
expect(find.textContaining('Insufficient talk power'), findsNothing);
expect(find.byType(SizedBox), findsOneWidget);
});
testWidgets('talk power warning renders blocked values', (tester) async {
await tester.pumpWidget(
const MaterialApp(
home: Scaffold(
body: TalkPowerWarning(
talkPower: 5,
neededTalkPower: 10,
talkPowerGranted: false,
),
),
),
);
expect(find.text('Insufficient talk power (5 < 10)'), findsOneWidget);
expect(find.byIcon(Icons.warning_amber), findsOneWidget);
});
}
@@ -0,0 +1,85 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:chanora_flutter/src/rust/api.dart' as rust;
import 'package:chanora_flutter/widgets/voice_settings_controls.dart';
void main() {
test('shared transmit mode segments expose all modes', () {
expect(transmitModeSegments.map((s) => s.value), [
rust.BridgeTransmitMode.ptt,
rust.BridgeTransmitMode.continuous,
rust.BridgeTransmitMode.voiceActivity,
]);
});
test('shared Android processing segments expose hardware and WebRTC', () {
expect(androidProcessingSegments.map((s) => s.value), [true, false]);
});
test('shared iOS processing segments expose VPIO and Sonora', () {
expect(iosProcessingSegments.map((s) => s.value), [
rust.BridgeIosVoiceProcessingMode.platformVoiceProcessing,
rust.BridgeIosVoiceProcessingMode.sonoraExperimental,
]);
});
test('shared VAD segments expose supported non-disabled backends', () {
expect(vadBackendSegments.map((s) => s.value), [
rust.BridgeVadBackend.webrtcVad,
rust.BridgeVadBackend.sileroOnnx,
rust.BridgeVadBackend.tenVad,
]);
});
testWidgets('shared segmented style applies compact visual density', (
tester,
) async {
await tester.pumpWidget(
MaterialApp(
home: Builder(
builder: (context) {
final style = voiceSegmentedButtonStyle(Theme.of(context));
return SegmentedButton<bool>(
style: style,
segments: androidProcessingSegments,
selected: const {true},
);
},
),
),
);
expect(find.byType(SegmentedButton<bool>), findsOneWidget);
});
testWidgets('audio processing toggle row renders dense layout', (
tester,
) async {
var value = false;
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: StatefulBuilder(
builder: (context, setState) {
return AudioProcessingToggleRow(
dense: true,
label: 'Noise suppression',
subtitle: 'Wiener filter',
value: value,
onChanged: (next) => setState(() => value = next),
);
},
),
),
),
);
await tester.tap(find.byType(Switch));
await tester.pump();
expect(value, isTrue);
expect(find.text('Noise suppression'), findsOneWidget);
expect(find.text('Wiener filter'), findsOneWidget);
});
}
@@ -0,0 +1,106 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:chanora_flutter/l10n/generated/app_localizations_en.dart';
import 'package:chanora_flutter/src/rust/api.dart' as rust;
import 'package:chanora_flutter/widgets/voice_status_summary.dart';
void main() {
final l10n = AppL10nEn();
test('voice mode labels follow localization', () {
expect(
voiceModeLabel(l10n, rust.BridgeTransmitMode.ptt),
l10n.voiceModePtt,
);
expect(
voiceModeLabel(l10n, rust.BridgeTransmitMode.continuous),
l10n.voiceModeContinuous,
);
expect(
voiceModeLabel(l10n, rust.BridgeTransmitMode.voiceActivity),
l10n.voiceModeVoiceActivity,
);
});
test('PTT summary shows touch hold hint and release tail', () {
final summary = voiceStatusSummary(
l10n: l10n,
transmitMode: rust.BridgeTransmitMode.ptt,
releaseTailMs: 200,
pttBoundKeyLabel: '',
isTouchOnly: true,
inputMuted: false,
outputMuted: false,
pttActive: false,
);
expect(summary.line1, '${l10n.voiceModePtt} · ${l10n.voicePttHoldHint}');
expect(summary.line2, '200${l10n.voiceReleaseTailHint} release tail · off');
expect(summary.statusText, l10n.voiceMicOff);
expect(summary.micOn, isFalse);
});
test('PTT summary shows bound key on hardware hosts', () {
final summary = voiceStatusSummary(
l10n: l10n,
transmitMode: rust.BridgeTransmitMode.ptt,
releaseTailMs: 120,
pttBoundKeyLabel: 'Space',
isTouchOnly: false,
inputMuted: false,
outputMuted: false,
pttActive: true,
);
expect(summary.line1, '${l10n.voiceModePtt} · Space');
expect(summary.micOn, isTrue);
expect(summary.statusText, l10n.voiceMicOn);
});
test('continuous mode is active unless muted or talk power blocked', () {
final active = voiceStatusSummary(
l10n: l10n,
transmitMode: rust.BridgeTransmitMode.continuous,
releaseTailMs: 0,
pttBoundKeyLabel: '',
isTouchOnly: false,
inputMuted: false,
outputMuted: false,
pttActive: false,
);
final muted = voiceStatusSummary(
l10n: l10n,
transmitMode: rust.BridgeTransmitMode.continuous,
releaseTailMs: 0,
pttBoundKeyLabel: '',
isTouchOnly: false,
inputMuted: true,
outputMuted: false,
pttActive: false,
);
expect(active.micOn, isTrue);
expect(muted.micOn, isFalse);
expect(muted.statusText, '${l10n.voiceMicOff} (muted)');
});
test('talk power block overrides active mic state', () {
final summary = voiceStatusSummary(
l10n: l10n,
transmitMode: rust.BridgeTransmitMode.continuous,
releaseTailMs: 0,
pttBoundKeyLabel: '',
isTouchOnly: false,
inputMuted: false,
outputMuted: false,
pttActive: true,
talkPower: 5,
neededTalkPower: 10,
talkPowerGranted: false,
);
expect(summary.talkPowerBlocked, isTrue);
expect(summary.micOn, isFalse);
expect(summary.statusText, 'Insufficient permission');
});
}
@@ -4,6 +4,7 @@
list(APPEND FLUTTER_PLUGIN_LIST
connectivity_plus
share_plus
url_launcher_windows
)
+340 -93
View File
@@ -60,13 +60,55 @@ pub use chanora_audio::{
};
pub use chanora_audio::{PttBinding, PttInputClass};
pub use chanora_diagnostics::{
DiagnosticExport, InMemoryLogSink, KnownSecretRegistry, RedactingLogLayer, Redactor,
DiagnosticExport, InMemoryLogSink, KnownSecretRegistry, ProtocolEventRecorder,
RedactingLogLayer, Redactor, DEFAULT_LOG_CAPACITY,
};
pub use chanora_protocol::{
ChannelInfo, ClientInfo, ConnectConfig, DisconnectReason, ProtocolError, ServerSnapshot,
ChannelInfo, ChatMessage, ClientInfo, ConnectConfig, DisconnectReason, MessageTarget,
ProtocolError, ServerSnapshot,
};
pub use chanora_storage::{Bookmark, BookmarkRepository, IdentityFileStore};
/// Privacy-safe snapshot of the active PTT capability.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PttDescriptorSnapshot {
/// Stable capability level name.
pub level: String,
/// Stable backend identifier.
pub backend_id: String,
/// Coarse bound input class; empty when no binding is active.
pub bound_input_class: String,
}
impl From<PttBackendDescriptor> for PttDescriptorSnapshot {
fn from(desc: PttBackendDescriptor) -> Self {
Self {
level: desc.level.as_str().to_string(),
backend_id: desc.backend_id.to_string(),
bound_input_class: desc.bound_input_class.unwrap_or("").to_string(),
}
}
}
/// Persisted PTT binding state exposed to callers.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PersistedPttBinding {
/// Stable input category string (`""`, `"keyboard"`, or
/// `"mouse-side-button"`).
pub input_class: String,
/// Display-only key label; empty when no binding is active.
pub key_label: String,
}
impl PersistedPttBinding {
fn empty() -> Self {
Self {
input_class: String::new(),
key_label: String::new(),
}
}
}
/// Errors that can arise during top-level orchestration.
#[derive(Debug, Error)]
pub enum CoreError {
@@ -202,6 +244,22 @@ pub enum SessionEvent {
/// Platform-provided resume hint. For begin events this is false.
should_resume: bool,
},
/// A text message was received from the server.
ChatMessage {
/// Client id of the sender.
sender_id: u64,
/// Nickname of the sender.
sender_name: String,
/// Message content.
message: String,
/// Target scope (server/channel/private/poke).
target: MessageTarget,
},
/// Audio route changed (speaker/earpiece/BT/wired headset).
AudioRouteChanged {
/// New audio route.
route: AudioRoute,
},
}
/// Bridge-safe mirror of channel-join projection sync state.
@@ -261,6 +319,43 @@ pub enum NetworkState {
/// fall behind we'd rather skip than block the supervisor.
const EVENT_CHANNEL_CAPACITY: usize = 64;
/// Network diagnostics snapshot collected across connection lifetimes.
#[derive(Debug, Clone, Default)]
struct NetworkDiagnostics {
/// Total count of connects (including the initial one).
connect_count: u64,
/// Count of disconnects (graceful + loss).
disconnect_count: u64,
/// Recent loss reasons (last 8, ring buffer).
loss_reasons: Vec<String>,
}
impl NetworkDiagnostics {
fn record_connect(&mut self) {
self.connect_count = self.connect_count.saturating_add(1);
}
fn record_loss(&mut self, reason: &str) {
self.disconnect_count = self.disconnect_count.saturating_add(1);
if self.loss_reasons.len() >= 8 {
self.loss_reasons.remove(0);
}
self.loss_reasons.push(reason.to_string());
}
fn summary(&self) -> String {
let mut s = format!(
"connects: {}\ndisconnects: {}\n",
self.connect_count, self.disconnect_count
);
if !self.loss_reasons.is_empty() {
s.push_str(&format!(
"loss_reasons: [{}]\n",
self.loss_reasons.join(", ")
));
}
s
}
}
struct SupervisorInner {
/// Optional cached AudioEngineConfig — set when start_audio is
/// first called, used to re-create the engine after a reconnect.
@@ -287,10 +382,6 @@ struct ConnectedState {
/// Supervisor task handle. Awaited on disconnect for clean
/// teardown.
supervisor: Option<JoinHandle<()>>,
/// Connection config used to dial this connection; retained for
/// future diagnostics. The supervisor task holds its own clone.
#[allow(dead_code)]
cfg: ConnectConfig,
/// Audio supervision state. Wrapped in Arc<Mutex<_>> so the
/// supervisor and the public API both see updates.
sup_inner: Arc<Mutex<SupervisorInner>>,
@@ -345,13 +436,6 @@ pub struct ChanoraSession {
/// Release-tail timer (SDD-096). Drives the selector's
/// `ptt_held` input from PTT key edges.
release_tail: Arc<ReleaseTailTimer>,
/// Missed-key-up watchdog (SAD-079 / DEC-028). Subscribes to
/// `voice_selector.subscribe_ptt_held()` so it only fires when
/// an actual PTT key has been "stuck" for the configured
/// timeout (default 30 s). Lives on the session because it
/// must outlive engine restarts. Spawned lazily on the first
/// `start_audio` because it needs a tokio runtime context.
ptt_watchdog: Arc<Mutex<Option<chanora_audio::MissedKeyUpWatchdog>>>,
/// Last PTT binding the user requested via `set_ptt_binding`.
/// Kept here so it survives the gap between user-saving a
/// binding (which may happen before any audio is running) and
@@ -361,6 +445,16 @@ pub struct ChanoraSession {
/// the binding survives app restarts.
pending_binding: Arc<Mutex<Option<PttBinding>>>,
next_connection_epoch: Arc<Mutex<u64>>,
/// SRS-100: Network diagnostics snapshot across connection
/// lifetimes (reconnect counts, loss reasons).
network_diag: Arc<Mutex<NetworkDiagnostics>>,
/// SRS-097/098: Protocol event recorder for diagnostic export
/// and state-sync replay verification.
event_recorder: Arc<Mutex<chanora_diagnostics::ProtocolEventRecorder>>,
/// SRS-026: Preferred input device name.
preferred_input_device: Arc<Mutex<Option<String>>>,
/// SRS-026: Preferred output device name.
preferred_output_device: Arc<Mutex<Option<String>>>,
}
impl ChanoraSession {
@@ -387,8 +481,13 @@ impl ChanoraSession {
voice_selector: selector,
release_tail,
pending_binding: Arc::new(Mutex::new(None)),
ptt_watchdog: Arc::new(Mutex::new(None)),
next_connection_epoch: Arc::new(Mutex::new(1)),
network_diag: Arc::new(Mutex::new(NetworkDiagnostics::default())),
event_recorder: Arc::new(Mutex::new(chanora_diagnostics::ProtocolEventRecorder::new(
256,
))),
preferred_input_device: Arc::new(Mutex::new(None)),
preferred_output_device: Arc::new(Mutex::new(None)),
}
}
@@ -440,16 +539,16 @@ impl ChanoraSession {
// exist yet (no audio engine running), so we stash the
// binding in `pending_binding`; it gets applied when the
// controller arms inside `start_audio`.
let (input_class_s, platform_key, _key_label) = store.get_ptt_binding();
if !input_class_s.is_empty() || !platform_key.is_empty() {
let input_class = match input_class_s.as_str() {
let stored_binding = store.get_ptt_binding();
if !stored_binding.input_class.is_empty() || !stored_binding.platform_key.is_empty() {
let input_class = match stored_binding.input_class.as_str() {
"keyboard" => PttInputClass::Keyboard,
"mouse-side-button" => PttInputClass::MouseSideButton,
_ => PttInputClass::None,
};
let binding = PttBinding {
input_class,
platform_key,
platform_key: stored_binding.platform_key,
};
*self.pending_binding.lock().await = Some(binding);
}
@@ -589,32 +688,94 @@ impl ChanoraSession {
audio_running: false,
}));
let supervisor = tokio::spawn(supervisor_loop(
self.inner.clone(),
self.events_tx.clone(),
cfg.clone(),
lost_rx,
probe,
let supervisor = tokio::spawn(supervisor_loop(SupervisorContext {
state_arc: self.inner.clone(),
events_tx: self.events_tx.clone(),
initial_cfg: cfg.clone(),
initial_lost_rx: lost_rx,
initial_probe: probe,
cancel_rx,
sup_inner.clone(),
self.network_tx.subscribe(),
self.voice_selector.clone(),
self.pending_binding.clone(),
self.release_tail.clone(),
self.next_connection_epoch.clone(),
));
sup_inner: sup_inner.clone(),
network_rx: self.network_tx.subscribe(),
network_diag: self.network_diag.clone(),
event_recorder: self.event_recorder.clone(),
voice_selector: self.voice_selector.clone(),
pending_binding: self.pending_binding.clone(),
release_tail: self.release_tail.clone(),
next_connection_epoch: self.next_connection_epoch.clone(),
}));
let _ = self.events_tx.send(SessionEvent::Connected {
server_name: snap.server_name.clone(),
});
// Record connect (SRS-100).
{
let mut diag = self.network_diag.lock().await;
diag.record_connect();
}
// Record protocol event (SRS-097).
{
let mut rec = self.event_recorder.lock().await;
rec.record_connected(&snap.server_name);
}
// Auto-save as a recent server (SRS-085). If a bookmark with
// the same host+port already exists, update it; otherwise
// create a new one with a default display name.
{
let bm_guard = self.bookmark_store.lock().await;
if let Some(repo) = bm_guard.as_ref() {
let host = cfg.address.clone();
let display = snap.server_name.clone();
let nickname = cfg.nickname.clone();
let password = cfg.password.clone();
let bm = Bookmark {
id: 0,
display_name: display,
host,
nickname,
password,
};
if let Err(e) = repo.upsert_or_add(&bm) {
warn!(target: "chanora_core", error = %e, "could not auto-save recent server");
}
}
}
// Forward inbound chat messages from the protocol adapter
// to the event broadcast stream. Chat is infrequent (~human
// typing rate), so a simple loop with try_recv + yield is fine.
if let Some(chat_rx) = client.take_chat_rx() {
let ev_tx = self.events_tx.clone();
tokio::spawn(async move {
use tokio::time::{sleep, Duration};
let mut rx = chat_rx;
loop {
match rx.try_recv() {
Ok(msg) => {
let _ = ev_tx.send(SessionEvent::ChatMessage {
sender_id: msg.sender_id.0,
sender_name: msg.sender_name,
message: msg.message,
target: msg.target,
});
}
Err(tokio::sync::mpsc::error::TryRecvError::Disconnected) => break,
Err(tokio::sync::mpsc::error::TryRecvError::Empty) => {
sleep(Duration::from_millis(200)).await;
}
}
}
});
}
*guard = Some(ConnectedState {
protocol: client,
audio: None,
ptt_controller: None,
cancel_tx: Some(cancel_tx),
supervisor: Some(supervisor),
cfg,
sup_inner,
join_state,
channel_passwords: HashMap::new(),
@@ -701,11 +862,56 @@ impl ChanoraSession {
self.inner.lock().await.is_some()
}
/// Send a text message to the specified target.
pub async fn send_text_message(
&self,
message: String,
target: MessageTarget,
) -> Result<(), CoreError> {
if message.trim().is_empty() {
return Ok(());
}
let guard = self.inner.lock().await;
let state = guard.as_ref().ok_or(CoreError::NotConnected)?;
state.protocol.send_text_message(message, target).await?;
Ok(())
}
/// Get a network diagnostics summary (SRS-100).
pub async fn network_diagnostics_summary(&self) -> String {
let diag = self.network_diag.lock().await;
diag.summary()
}
/// Drain and return recorded protocol events (SRS-097).
pub async fn drain_protocol_events(&self) -> Vec<String> {
let mut rec = self.event_recorder.lock().await;
rec.drain()
}
/// Record a platform lifecycle event (SRS-138).
pub async fn record_lifecycle_event(&self, state: &str) {
let mut rec = self.event_recorder.lock().await;
rec.record_lifecycle(state);
}
/// Start the audio engine attached to the current connection.
/// Fails if not connected. Idempotent — calling twice replaces
/// the engine. Stores the config so the supervisor can restart
/// audio after a reconnect.
pub async fn start_audio(&self, mut cfg: AudioEngineConfig) -> Result<(), CoreError> {
// Apply stored device preferences (SRS-026).
{
let dev_in = self.preferred_input_device.lock().await;
let dev_out = self.preferred_output_device.lock().await;
if cfg.input_device_name.is_none() {
cfg.input_device_name = dev_in.clone();
}
if cfg.output_device_name.is_none() {
cfg.output_device_name = dev_out.clone();
}
}
let mut guard = self.inner.lock().await;
let state = guard.as_mut().ok_or(CoreError::NotConnected)?;
@@ -782,32 +988,6 @@ impl ChanoraSession {
let controller = ptt::PttController::new(self.release_tail.clone());
state.ptt_controller = Some(controller.clone());
// SAD-079 / DEC-028 missed-key-up watchdog: DISABLED for
// P0 per owner decision (2026-05-16). The original 30 s
// ceiling caused real users to be cut off mid-sentence in
// PTT mode whenever they spoke for longer than the
// timeout. The watchdog's purpose (catching OS-level
// key-up loss when the app loses focus / is minimised /
// hits App Nap) is real, but the fixed-timeout
// implementation is the wrong shape.
//
// P1 redesign options under consideration:
// * Raise ceiling to ~5 min (owner-tunable, per DEC-028)
// * Add Windows GetAsyncKeyState / macOS
// CGEventSourceKeyState / X11 XQueryKeymap polling so
// we detect the actual OS desync directly instead of
// timing out on legitimate long speech
// * Combine with an RMS-silence check once the audio
// level meter (P1) lands, so the watchdog only fires
// when the user has been "transmitting" silence for
// the entire window
//
// Until P1 picks one of those, we ship without the
// watchdog. The existing MissedKeyUpWatchdog code path
// and tests remain in place so the P1 work can re-enable
// it with the chosen detection strategy.
let _ = &self.ptt_watchdog;
// Apply any binding the user saved before audio was running
// (SDD-094 follow-up). Persistence + caching happen in
// `set_ptt_binding`; here we forward the cached value to
@@ -879,12 +1059,6 @@ impl ChanoraSession {
Ok(())
}
/// Update the active PTT binding (gen2 v0.9.3 / DEC-026). The
/// binding flows into the platform backend's `rebind` hook via
/// the [`ptt::PttController`] (SDD-088) and the freshly-published
/// `PttBackendDescriptor` is broadcast as
/// `SessionEvent::PttCapability` so the UI badge updates
/// immediately. The audio engine must be running.
/// Update the active PTT binding (gen2 v0.9.3 / DEC-026).
///
/// This call must succeed even when the audio engine is not
@@ -930,30 +1104,24 @@ impl ChanoraSession {
Ok(())
}
/// Returns the active PTT capability descriptor as a triple
/// `(level, backend_id, bound_input_class)`. Useful for the
/// initial UI render before the first `PttCapability` event
/// arrives. Returns the universal Focused descriptor when no
/// audio engine is running.
pub async fn ptt_descriptor(&self) -> (String, String, String) {
/// Returns the active PTT capability descriptor. Useful for the
/// initial UI render before the first `PttCapability` event arrives.
/// Returns the universal Focused descriptor when no audio engine is
/// running.
pub async fn ptt_descriptor(&self) -> PttDescriptorSnapshot {
let guard = self.inner.lock().await;
let desc = match guard.as_ref().and_then(|s| s.ptt_controller.as_ref()) {
Some(controller) => controller.descriptor().await,
None => PttBackendDescriptor::focused(),
};
(
desc.level.as_str().to_string(),
desc.backend_id.to_string(),
desc.bound_input_class.unwrap_or("").to_string(),
)
desc.into()
}
/// Read the persisted PTT binding as `(input_class, key_label)`.
/// Used by the Flutter side at launch so the badge and the
/// mode line can show the user's saved hotkey before any audio
/// engine has spun up. Empty strings indicate no binding has
/// been saved yet.
pub async fn get_ptt_binding(&self) -> (String, String) {
/// Read the persisted PTT binding. Used by the Flutter side at launch
/// so the badge and the mode line can show the user's saved hotkey
/// before any audio engine has spun up. Empty strings indicate no
/// binding has been saved yet.
pub async fn get_ptt_binding(&self) -> PersistedPttBinding {
// 1) Prefer the in-memory pending binding (most-recent
// save, possibly not yet flushed to disk on a slow FS).
if let Some(b) = self.pending_binding.lock().await.clone() {
@@ -962,15 +1130,21 @@ impl ChanoraSession {
PttInputClass::Keyboard => "keyboard",
PttInputClass::MouseSideButton => "mouse-side-button",
};
return (class_s.to_string(), b.platform_key);
return PersistedPttBinding {
input_class: class_s.to_string(),
key_label: b.platform_key,
};
}
// 2) Fall back to the persisted file (case: app just
// started, init_storage already ran).
if let Some(store) = self.identity_store.lock().await.as_ref() {
let (class_s, _platform_key, key_label) = store.get_ptt_binding();
return (class_s, key_label);
let stored_binding = store.get_ptt_binding();
return PersistedPttBinding {
input_class: stored_binding.input_class,
key_label: stored_binding.key_label,
};
}
(String::new(), String::new())
PersistedPttBinding::empty()
}
/// Move our own client to `channel_id`. Optional channel
@@ -1040,12 +1214,27 @@ impl ChanoraSession {
Ok(())
}
/// Read audio engine statistics: (frames_sent, frames_received, ptt_active).
/// Set per-client output volume (SRS-075). `1.0` is unity, `0.0`
/// mutes. No-op if audio is not started or client has no active
/// voice queue.
pub async fn set_client_volume(&self, client_id: u64, volume: f32) -> Result<(), CoreError> {
let guard = self.inner.lock().await;
let state = guard.as_ref().ok_or(CoreError::NotConnected)?;
let audio = state.audio.as_ref().ok_or(CoreError::AudioNotStarted)?;
audio.set_client_volume(client_id, volume);
Ok(())
}
/// Read audio engine statistics: (frames_sent, frames_received, transmit_active).
pub async fn audio_stats(&self) -> Result<(u32, u32, bool), CoreError> {
let guard = self.inner.lock().await;
let state = guard.as_ref().ok_or(CoreError::NotConnected)?;
let audio = state.audio.as_ref().ok_or(CoreError::AudioNotStarted)?;
Ok((audio.frames_sent(), audio.frames_received(), audio.ptt()))
Ok((
audio.frames_sent(),
audio.frames_received(),
audio.transmit_active(),
))
}
/// Apply the Rust-owned P1 audio-processing configuration.
@@ -1103,7 +1292,8 @@ impl ChanoraSession {
Ok(())
}
/// iOS route-change hook (SDD-100). No-op when audio is not running.
/// Route-change hook (SDD-100/SRS-112). Updates audio processing
/// config and notifies Flutter of the new route.
pub async fn ios_handle_route_change(&self, route: AudioRoute) -> Result<(), CoreError> {
let guard = self.inner.lock().await;
if let Some(state) = guard.as_ref() {
@@ -1113,6 +1303,9 @@ impl ChanoraSession {
audio.set_audio_processing_config(config)?;
}
}
let _ = self
.events_tx
.send(SessionEvent::AudioRouteChanged { route });
Ok(())
}
@@ -1468,6 +1661,19 @@ impl ChanoraSession {
self.voice_selector.hard_mute()
}
/// Set the preferred input device name (SRS-026). Takes effect
/// on the next audio engine start.
pub async fn set_input_device(&self, name: Option<String>) -> Result<(), CoreError> {
*self.preferred_input_device.lock().await = name;
Ok(())
}
/// Set the preferred output device name (SRS-026).
pub async fn set_output_device(&self, name: Option<String>) -> Result<(), CoreError> {
*self.preferred_output_device.lock().await = name;
Ok(())
}
/// Update the release-tail (SDD-096). Clamped to `0..=500` ms
/// inclusive. Persists best-effort and re-emits voice state.
pub async fn set_release_tail_ms(&self, ms: u32) -> Result<(), CoreError> {
@@ -1588,21 +1794,40 @@ const WATCHDOG_PROBE_TIMEOUT: Duration = Duration::from_secs(4);
/// declares the connection lost.
const WATCHDOG_MAX_MISSES: u32 = 3;
#[allow(clippy::too_many_arguments)]
async fn supervisor_loop(
struct SupervisorContext {
state_arc: Arc<Mutex<Option<ConnectedState>>>,
events_tx: broadcast::Sender<SessionEvent>,
initial_cfg: ConnectConfig,
initial_lost_rx: oneshot::Receiver<chanora_protocol::DisconnectReason>,
initial_probe: chanora_protocol::SnapshotProbe,
mut cancel_rx: oneshot::Receiver<()>,
cancel_rx: oneshot::Receiver<()>,
sup_inner: Arc<Mutex<SupervisorInner>>,
mut network_rx: watch::Receiver<NetworkState>,
network_rx: watch::Receiver<NetworkState>,
network_diag: Arc<Mutex<NetworkDiagnostics>>,
event_recorder: Arc<Mutex<chanora_diagnostics::ProtocolEventRecorder>>,
voice_selector: Arc<TransmitModeSelector>,
pending_binding: Arc<Mutex<Option<PttBinding>>>,
release_tail: Arc<ReleaseTailTimer>,
next_connection_epoch: Arc<Mutex<u64>>,
) {
}
async fn supervisor_loop(ctx: SupervisorContext) {
let SupervisorContext {
state_arc,
events_tx,
initial_cfg,
initial_lost_rx,
initial_probe,
mut cancel_rx,
sup_inner,
mut network_rx,
network_diag,
event_recorder,
voice_selector,
pending_binding,
release_tail,
next_connection_epoch,
} = ctx;
let mut lost_rx = initial_lost_rx;
let mut probe = initial_probe;
let mut cfg = initial_cfg;
@@ -1700,6 +1925,11 @@ async fn supervisor_loop(
channels: snap.channels.len() as u32,
clients: snap.clients.len() as u32,
});
// Record protocol event (SRS-097).
event_recorder.lock().await.record_snapshot_changed(
snap.channels.len(),
snap.clients.len(),
);
}
}
Ok(Err(e)) => {
@@ -1747,6 +1977,12 @@ async fn supervisor_loop(
reason: reason_str.clone(),
});
// Record network diagnostic (SRS-100).
{
let mut diag = network_diag.lock().await;
diag.record_loss(&reason_str);
}
// Stop the audio engine before reconnect — its
// voice_out_tx points at the dead protocol client.
// Also drop the dead protocol client itself so
@@ -1778,6 +2014,11 @@ async fn supervisor_loop(
attempt,
delay_secs,
});
// Record protocol event (SRS-097).
event_recorder
.lock()
.await
.record_reconnecting(attempt as u64, delay_secs as u64);
info!(
target: "chanora_core",
attempt,
@@ -2114,6 +2355,7 @@ mod tests {
name: "a".into(),
order: 0,
has_password: false,
needed_talk_power: None,
},
ChannelInfo {
id: chanora_protocol::ChannelId(2),
@@ -2121,6 +2363,7 @@ mod tests {
name: "b".into(),
order: 1,
has_password: false,
needed_talk_power: None,
},
],
clients: vec![ClientInfo {
@@ -2131,6 +2374,8 @@ mod tests {
output_muted: false,
is_speaking: false,
is_server_query: false,
talk_power: 0,
talk_power_granted: false,
}],
own_client_id: 10,
};
@@ -2155,6 +2400,7 @@ mod tests {
name: "b".into(),
order: 1,
has_password: false,
needed_talk_power: None,
},
ChannelInfo {
id: chanora_protocol::ChannelId(1),
@@ -2162,6 +2408,7 @@ mod tests {
name: "a".into(),
order: 0,
has_password: false,
needed_talk_power: None,
},
],
clients: vec![],
+1 -8
View File
@@ -28,6 +28,7 @@ audiopus = "0.3.0-rc.0"
# Connection type stays inside chanora_protocol.
tsclientlib = { git = "https://github.com/ReSpeak/tsclientlib.git", rev = "04aa2491", default-features = false, features = ["audio"] }
tokio = { version = "1", features = ["sync", "rt", "macros", "time"] }
rustfft = "6.2.0"
[target.'cfg(all(not(target_os = "android"), not(target_os = "ios"), not(target_os = "macos")))'.dependencies]
# Desktop audio I/O for Windows capture/playback and Linux capture.
@@ -35,11 +36,6 @@ tokio = { version = "1", features = ["sync", "rt", "macros", "time"] }
# AudioUnits via `coreaudio-rs` for the voice path.
cpal = "0.17.3"
[target.'cfg(not(target_os = "android"))'.dependencies]
# Desktop/iOS: native TLS maps to the platform TLS backend (Security.framework
# on Apple, SChannel on Windows, system OpenSSL on Linux/BSD).
reqwest = { version = "0.13", default-features = false, features = ["charset", "http2", "native-tls"] }
[target.'cfg(any(target_os = "ios", target_os = "macos"))'.dependencies]
# Direct CoreAudio AudioUnit access on Apple platforms (DEC-011 follow-up).
# cpal's Apple path does not expose the voice-processing controls Chanora
@@ -64,9 +60,6 @@ ort = { version = "2.0.0-rc.12", default-features = false, features = ["std", "n
ort = { version = "2.0.0-rc.12", default-features = false, features = ["load-dynamic", "ndarray", "api-24"] }
[target.'cfg(target_os = "android")'.dependencies]
# Android cross-builds should not pull OpenSSL. Use rustls here while keeping
# native-tls for Apple targets where aws-lc/rustls is problematic for iOS.
reqwest = { version = "0.13", default-features = false, features = ["charset", "http2", "rustls"] }
# JNI bindings to flip Android's AudioManager into MODE_IN_COMMUNICATION
# when the voice-comm preset is requested. ndk_context is initialised
# by the bridge crate's android_init shim.
@@ -95,7 +95,7 @@ fn bench_capture_alloc_count(c: &mut Criterion) {
if let Some(parent) = path.parent() {
let _ = std::fs::create_dir_all(parent);
}
let _ = std::fs::write(&path, format!("{}", delta));
let _ = std::fs::write(&path, delta.to_string());
}
}
+53 -77
View File
@@ -50,7 +50,7 @@ use crate::mobile_voice_backend::{
AchievedPerformanceMode, AchievedSharingMode, AndroidAudioDiagnostics,
AndroidVoiceStreamConfig, AudioSessionId, BackendError, BackendEvent, BackendEventRx,
BackendEventTx, EffectEngagement, EffectEngine, InputPresetChoice, MobileVoiceAudioBackend,
SharingModeChoice,
SharingModeChoice, VoiceAudioParams,
};
use chanora_protocol::OutPacket;
use tsclientlib::audio::AudioHandler;
@@ -102,8 +102,8 @@ impl RenderReferenceBuffer {
fn write(&self, frame: &[f32; RENDER_REF_SAMPLES]) {
let idx = self.write_idx.load(Ordering::Relaxed);
unsafe {
let slot =
&self.buf[idx] as *const [f32; RENDER_REF_SAMPLES] as *mut [f32; RENDER_REF_SAMPLES];
let slot = &self.buf[idx] as *const [f32; RENDER_REF_SAMPLES]
as *mut [f32; RENDER_REF_SAMPLES];
(*slot).copy_from_slice(frame);
}
self.write_idx
@@ -148,6 +148,7 @@ struct AndroidCaptureState {
ten_vad_worker: Option<crate::vad::TenOnnxVadWorker>,
current_vad_backend: crate::VadBackend,
silero_model_epoch: u64,
ten_model_epoch: u64,
capture_frame_seq: u64,
vad_state: crate::voice_activity::VoiceActivityStateMachine,
webrtc_apm_processor: crate::processor::WebRtcApmProcessor,
@@ -208,6 +209,7 @@ impl AndroidCaptureState {
ten_vad_worker: None,
current_vad_backend: crate::VadBackend::WebrtcVad,
silero_model_epoch: crate::vad::silero_model_epoch(),
ten_model_epoch: crate::vad::ten_model_epoch(),
capture_frame_seq: 0,
vad_state: crate::voice_activity::VoiceActivityStateMachine::default(),
webrtc_apm_processor: crate::processor::WebRtcApmProcessor::with_config(
@@ -227,8 +229,7 @@ impl AndroidCaptureState {
fn ingest_i16(&mut self, samples: &[i16]) {
let mut offset = 0;
while offset < samples.len() {
let remaining =
crate::frame::FRAME_10MS_SAMPLES - self.pending_10ms_len;
let remaining = crate::frame::FRAME_10MS_SAMPLES - self.pending_10ms_len;
let take = remaining.min(samples.len() - offset);
self.pending_10ms[self.pending_10ms_len..self.pending_10ms_len + take]
.copy_from_slice(&samples[offset..offset + take]);
@@ -249,11 +250,8 @@ impl AndroidCaptureState {
while self.pcm_accum.len() >= crate::frame::FRAME_20MS_SAMPLES {
let mut frame = [0i16; crate::frame::FRAME_20MS_SAMPLES];
frame.copy_from_slice(
&self.pcm_accum[..crate::frame::FRAME_20MS_SAMPLES],
);
self.pcm_accum
.drain(..crate::frame::FRAME_20MS_SAMPLES);
frame.copy_from_slice(&self.pcm_accum[..crate::frame::FRAME_20MS_SAMPLES]);
self.pcm_accum.drain(..crate::frame::FRAME_20MS_SAMPLES);
match self.encoder.encode(&frame, &mut self.opus_out[..]) {
Ok(len) => {
crate::opus_voice::send_voip_frame(
@@ -289,18 +287,26 @@ impl AndroidCaptureState {
fn mark_vad_fallback_active(&mut self, failed_backend: crate::VadBackend) {
if self.fallback_warned_backend != Some(failed_backend) {
self.fallback_warned_backend = Some(failed_backend);
warn!(
target: "chanora_audio",
backend = failed_backend.as_str(),
"android: VAD backend unavailable; using WebRTC fallback for runtime detection"
);
// Warm-up period: ONNX workers need ~100ms to process first frame.
// Don't flag as a problem if the capture has just started.
if self.capture_frame_seq < 128 {
info!(
target: "chanora_audio",
backend = failed_backend.as_str(),
seq = self.capture_frame_seq,
"android: VAD backend warming up; using WebRTC fallback"
);
} else {
warn!(
target: "chanora_audio",
backend = failed_backend.as_str(),
"android: VAD backend unavailable; using WebRTC fallback for runtime detection"
);
}
}
}
fn process_10ms_capture_frame(
&mut self,
samples: &[i16; crate::frame::FRAME_10MS_SAMPLES],
) {
fn process_10ms_capture_frame(&mut self, samples: &[i16; crate::frame::FRAME_10MS_SAMPLES]) {
let mut frame = [0.0_f32; crate::frame::FRAME_10MS_SAMPLES];
for (dst, src) in frame.iter_mut().zip(samples.iter().copied()) {
*dst = crate::frame::i16_to_f32(src);
@@ -327,11 +333,15 @@ impl AndroidCaptureState {
// VAD backend switching (mirrors iOS Raw path).
let silero_epoch = crate::vad::silero_model_epoch();
let silero_changed = vad_backend == crate::VadBackend::SileroOnnx
&& silero_epoch != self.silero_model_epoch;
if vad_backend != self.current_vad_backend || silero_changed {
let ten_epoch = crate::vad::ten_model_epoch();
let silero_changed =
vad_backend == crate::VadBackend::SileroOnnx && silero_epoch != self.silero_model_epoch;
let ten_changed =
vad_backend == crate::VadBackend::TenVad && ten_epoch != self.ten_model_epoch;
if vad_backend != self.current_vad_backend || silero_changed || ten_changed {
self.current_vad_backend = vad_backend;
self.silero_model_epoch = silero_epoch;
self.ten_model_epoch = ten_epoch;
self.fallback_warned_backend = None;
match vad_backend {
crate::VadBackend::SileroOnnx => {
@@ -376,52 +386,52 @@ impl AndroidCaptureState {
} else if vad_backend == crate::VadBackend::SileroOnnx {
if let Some(worker) = self.silero_vad_worker.as_ref() {
let enqueued = worker.try_send(capture_seq, &frame);
if enqueued && !worker.is_stale(capture_seq) {
if !worker.is_stale(capture_seq) {
let p = worker.latest_probability();
crate::vad::VadOutput {
probability: p,
speech: p >= 0.5,
}
} else if enqueued {
// Warm-up: worker dispatched but hasn't finished yet.
// Default to no-speech until first result arrives (~100ms).
crate::vad::VadOutput {
probability: 0.0,
speech: false,
}
} else {
used_fallback_vad = true;
self.mark_vad_fallback_active(vad_backend);
crate::vad::VoiceActivityDetector::process_10ms(
&mut self.vad_detector,
&frame,
)
crate::vad::VoiceActivityDetector::process_10ms(&mut self.vad_detector, &frame)
}
} else {
used_fallback_vad = true;
self.mark_vad_fallback_active(vad_backend);
crate::vad::VoiceActivityDetector::process_10ms(
&mut self.vad_detector,
&frame,
)
crate::vad::VoiceActivityDetector::process_10ms(&mut self.vad_detector, &frame)
}
} else if vad_backend == crate::VadBackend::TenVad {
if let Some(worker) = self.ten_vad_worker.as_ref() {
let enqueued = worker.try_send(capture_seq, &frame);
if enqueued && !worker.is_stale(capture_seq) {
if !worker.is_stale(capture_seq) {
let p = worker.latest_probability();
crate::vad::VadOutput {
probability: p,
speech: p >= 0.5,
}
} else if enqueued {
crate::vad::VadOutput {
probability: 0.0,
speech: false,
}
} else {
used_fallback_vad = true;
self.mark_vad_fallback_active(vad_backend);
crate::vad::VoiceActivityDetector::process_10ms(
&mut self.vad_detector,
&frame,
)
crate::vad::VoiceActivityDetector::process_10ms(&mut self.vad_detector, &frame)
}
} else {
used_fallback_vad = true;
self.mark_vad_fallback_active(vad_backend);
crate::vad::VoiceActivityDetector::process_10ms(
&mut self.vad_detector,
&frame,
)
crate::vad::VoiceActivityDetector::process_10ms(&mut self.vad_detector, &frame)
}
} else {
crate::vad::VoiceActivityDetector::process_10ms(&mut self.vad_detector, &frame)
@@ -572,37 +582,6 @@ impl AudioOutputCallback for OutputCallback {
}
}
/// Bundle of engine-owned state shared with the Oboe audio callbacks.
/// Mirrors the parameter set that iOS `IosVoiceUnit::start()` receives
/// from the engine (SDD-120 amendment: Android Oboe-only audio path).
pub struct VoiceAudioParams {
/// Opus-encoded voice packets sent on this channel toward the
/// protocol layer.
pub voice_out_tx: mpsc::Sender<OutPacket>,
/// PTT transmission gate — true when the user holds the PTT key.
pub transmit_active: Arc<AtomicBool>,
/// Counter incremented per encoded frame sent.
pub frames_sent: Arc<AtomicU32>,
/// Pre-encode amplitude scale (1.0 = unity).
pub mic_gain: f32,
/// AudioHandler that inbound decode+mix feeds into; the Oboe output
/// callback pulls mixed stereo f32 from it.
pub handler: Arc<Mutex<AudioHandler<SessionAudioId>>>,
/// Master output gain (f32 bits stored in AtomicU32 for lock-free
/// cross-thread read from the realtime audio callback).
pub output_gain: Arc<AtomicU32>,
/// True = output silence regardless of incoming voice frames.
pub output_muted: Arc<AtomicBool>,
/// Optional TransmitModeSelector for VoiceActivity transmit mode.
/// The capture callback calls set_voice_activity_open on this when
/// VAD detects speech. None means VoiceActivity mode is disabled.
pub voice_activity_selector: Option<Arc<crate::TransmitModeSelector>>,
/// Shared audio-processing config (WebRTC APM flags, VAD backend).
pub audio_processing_config: Arc<Mutex<crate::AudioProcessingConfig>>,
/// Shared audio-processing statistics for diagnostics.
pub audio_processing_stats: Arc<crate::SharedAudioProcessingStats>,
}
// --- The backend itself ------------------------------------------
/// Android voice-audio backend (SDD-111). Owns one input + one
@@ -647,8 +626,7 @@ impl AndroidVoiceUnit {
/// `params` bundles the engine-owned state shared with the Oboe
/// audio callbacks (SDD-120 amendment: Android Oboe-only audio
/// path — capture pipeline, playback pull, and PTT gate).
#[allow(clippy::too_many_arguments)]
pub fn open(
pub(crate) fn open(
cfg: &AndroidVoiceStreamConfig,
params: VoiceAudioParams,
) -> Result<Self, BackendError> {
@@ -761,9 +739,7 @@ impl AndroidVoiceUnit {
//
// For now: hardware effects require the system session ID.
// WebRTC APM software processing handles AEC/NS/AGC/HPF.
let session_id: Option<i32> = input_stream
.as_ref()
.and_then(|s| s.get_raw_session_id());
let session_id: Option<i32> = input_stream.as_ref().and_then(|s| s.get_raw_session_id());
// --- Open output stream (SDD-112) --------------------------
let output_builder = AudioStreamBuilder::default()
@@ -1558,7 +1534,7 @@ fn load_app_class<'local>(
Ok(c) => return Some(c),
Err(e) => {
let _ = env.exception_clear();
warn!(target: "chanora_audio", error = %e, class = slash_name, "android: find_class failed; retrying with app ClassLoader");
debug!(target: "chanora_audio", error = %e, class = slash_name, "android: find_class failed; retrying with app ClassLoader");
}
}
+7 -7
View File
@@ -207,12 +207,12 @@ impl AudioProcessingConfig {
.to_string(),
));
}
if self.ios_mode == IosVoiceProcessingMode::SonoraExperimental {
if self.processing_backend != AudioBackend::WebrtcApm {
return Err(AudioError::InvalidAudioProcessingConfig(
"ios raw processing mode requires the WebRTC APM backend".to_string(),
));
}
if self.ios_mode == IosVoiceProcessingMode::SonoraExperimental
&& self.processing_backend != AudioBackend::WebrtcApm
{
return Err(AudioError::InvalidAudioProcessingConfig(
"ios raw processing mode requires the WebRTC APM backend".to_string(),
));
}
Ok(())
}
@@ -246,7 +246,7 @@ mod tests {
assert_eq!(config.aec, EffectOwner::Platform);
assert_eq!(config.ns, EffectOwner::Platform);
assert_eq!(config.agc, EffectOwner::Platform);
assert_eq!(config.vad_backend, VadBackend::SileroOnnx);
assert_eq!(config.vad_backend, VadBackend::TenVad);
}
#[test]
+235 -167
View File
@@ -55,17 +55,98 @@ use audiopus::coder::Encoder as OpusEncoder;
pub struct SessionAudioId(pub u64);
/// Audio framing: 48 kHz mono, 20 ms = 960 samples per frame.
/// These constants are framing invariants of the engine and are
/// referenced from per-platform helpers (`try_open_capture` and
/// the CaptureState on cpal platforms; `ios_voice_unit` on iOS once
/// commits 3+4 land). The `allow(dead_code)` is here because in
/// the current commit the iOS VPIO callbacks are still no-op stubs
/// and don't reach these constants yet — they will in commit 3
/// when the input callback wires into CaptureState.
#[allow(dead_code)]
#[cfg(all(
not(target_os = "ios"),
not(target_os = "macos"),
not(target_os = "android")
))]
const SAMPLE_RATE: u32 = 48_000;
#[allow(dead_code)]
#[cfg(all(
not(target_os = "ios"),
not(target_os = "macos"),
not(target_os = "android")
))]
const FRAME_SAMPLES: usize = 48_000 / 50; // 960
/// List of available audio devices from the platform.
#[derive(Debug, Clone)]
pub struct AudioDeviceList {
/// Available input (capture) devices.
pub input_devices: Vec<AudioDeviceInfo>,
/// Available output (playback) devices.
pub output_devices: Vec<AudioDeviceInfo>,
}
/// Info about a single audio device.
#[derive(Debug, Clone)]
pub struct AudioDeviceInfo {
/// Human-readable device name from the OS.
pub name: String,
/// True if the OS reports this as the default device.
pub is_default: bool,
}
/// Enumerate available audio input and output devices.
/// On mobile platforms (iOS, Android) returns an empty list because
/// device selection is managed by the OS audio session.
#[cfg(all(
not(target_os = "ios"),
not(target_os = "macos"),
not(target_os = "android")
))]
pub fn list_audio_devices() -> AudioDeviceList {
use cpal::traits::HostTrait;
let mut list = AudioDeviceList {
input_devices: Vec::new(),
output_devices: Vec::new(),
};
let Ok(host) = cpal::default_host() else {
return list;
};
let default_in = host.default_input_device();
let default_out = host.default_output_device();
if let Ok(devices) = host.input_devices() {
for d in devices {
let name = d
.description()
.map(|n| n.name().to_owned())
.unwrap_or_default();
if !name.is_empty() {
let is_default = default_in
.as_ref()
.is_some_and(|di| di.description().is_ok_and(|dn| dn.name() == name.as_str()));
list.input_devices
.push(AudioDeviceInfo { name, is_default });
}
}
}
if let Ok(devices) = host.output_devices() {
for d in devices {
let name = d
.description()
.map(|n| n.name().to_owned())
.unwrap_or_default();
if !name.is_empty() {
let is_default = default_out
.as_ref()
.is_some_and(|di| di.description().is_ok_and(|dn| dn.name() == name.as_str()));
list.output_devices
.push(AudioDeviceInfo { name, is_default });
}
}
}
list
}
#[cfg(any(target_os = "ios", target_os = "macos", target_os = "android"))]
/// Enumerate available audio input and output devices.
pub fn list_audio_devices() -> AudioDeviceList {
AudioDeviceList {
input_devices: Vec::new(),
output_devices: Vec::new(),
}
}
/// Engine configuration.
#[derive(Clone)]
pub struct AudioEngineConfig {
@@ -91,6 +172,12 @@ pub struct AudioEngineConfig {
/// is rejected on Android because the P0 path intentionally has
/// no generic mobile-audio fallback.
pub mobile_voice_preset: bool,
/// Optional input device name override. When `None`, the system
/// default input device is used. Set to a device name from
/// [`list_audio_devices`] to pin a specific microphone.
pub input_device_name: Option<String>,
/// Optional output device name override.
pub output_device_name: Option<String>,
/// Optional selector used by P1 VoiceActivity to publish VAD state.
#[doc(hidden)]
pub voice_activity_selector: Option<Arc<crate::TransmitModeSelector>>,
@@ -103,6 +190,8 @@ impl std::fmt::Debug for AudioEngineConfig {
.field("ptt_initial", &self.ptt_initial)
.field("effects", &self.effects)
.field("mobile_voice_preset", &self.mobile_voice_preset)
.field("input_device_name", &self.input_device_name)
.field("output_device_name", &self.output_device_name)
.field(
"voice_activity_selector",
&self.voice_activity_selector.as_ref().map(|_| "present"),
@@ -118,6 +207,8 @@ impl Default for AudioEngineConfig {
ptt_initial: false,
effects: crate::AudioEffects::default(),
mobile_voice_preset: true,
input_device_name: None,
output_device_name: None,
voice_activity_selector: None,
}
}
@@ -145,7 +236,6 @@ pub struct AudioEngine {
output_muted: Arc<AtomicBool>,
audio_processing_config: Arc<Mutex<crate::AudioProcessingConfig>>,
audio_processing_stats: Arc<crate::SharedAudioProcessingStats>,
#[cfg(any(target_os = "ios", target_os = "macos"))]
audio_handler: Arc<Mutex<AudioHandler<SessionAudioId>>>,
#[cfg(any(target_os = "ios", target_os = "macos"))]
voice_out_tx: mpsc::Sender<OutPacket>,
@@ -204,23 +294,6 @@ pub struct AudioEngine {
/// denied microphone permission), PTT becomes a no-op and
/// `frames_sent` stays at 0.
capture_active: bool,
/// Missed-key-up watchdog (SDD-092). Dropping aborts the task.
/// The watchdog is independent of the PTT input backend — it
/// observes the gate directly. The platform input backend is
/// owned by `chanora_core::ptt::PttController` (SDD-088), not
/// by the engine.
///
/// In the post-rc.7 architecture this field is unused: the
/// missed-key-up watchdog now lives on the session and
/// subscribes to `TransmitModeSelector::subscribe_ptt_held`
/// rather than the gate. Watching the gate caused the watchdog
/// to fire in Continuous mode (where the gate is intentionally
/// pinned to `true`) which clearing surfaced as the bug
/// "Continuous transmission disabled after some time". The
/// field stays here as `None` for now to preserve the existing
/// engine-stop teardown flow; a follow-up commit can remove it
/// entirely.
ptt_watchdog: Option<crate::ptt::MissedKeyUpWatchdog>,
}
// cpal::Stream is not Send. We keep the engine pinned to the thread
@@ -241,7 +314,6 @@ unsafe impl Send for AudioEngine {}
unsafe impl Sync for AudioEngine {}
#[cfg(any(target_os = "ios", target_os = "macos"))]
#[allow(dead_code)]
enum IosVoiceBackend {
Vpio(crate::ios_voice_unit::IosVoiceUnit),
#[cfg(target_os = "ios")]
@@ -260,7 +332,9 @@ impl IosVoiceBackend {
}
#[cfg(target_os = "macos")]
{
Ok(())
match self {
Self::Vpio(_unit) => Ok(()),
}
}
}
@@ -274,41 +348,23 @@ impl IosVoiceBackend {
}
#[cfg(target_os = "macos")]
{
Ok(())
match self {
Self::Vpio(_unit) => Ok(()),
}
}
}
}
#[cfg(any(target_os = "ios", target_os = "macos"))]
#[allow(clippy::too_many_arguments)]
fn open_ios_voice_backend(
handler: Arc<Mutex<AudioHandler<SessionAudioId>>>,
output_gain: Arc<AtomicU32>,
output_muted: Arc<AtomicBool>,
voice_out_tx: mpsc::Sender<OutPacket>,
transmit_flag_for_capture: Arc<AtomicBool>,
frames_sent: Arc<AtomicU32>,
mic_gain: f32,
voice_activity_selector: Option<Arc<crate::TransmitModeSelector>>,
audio_processing_config: Arc<Mutex<crate::AudioProcessingConfig>>,
audio_processing_stats: Arc<crate::SharedAudioProcessingStats>,
params: crate::mobile_voice_backend::VoiceAudioParams,
) -> Result<IosVoiceBackend, AudioError> {
let _cfg = audio_processing_config.lock().unwrap().clone();
let _cfg = params.audio_processing_config.lock().unwrap().clone();
#[cfg(target_os = "ios")]
{
if _cfg.ios_mode == crate::IosVoiceProcessingMode::SonoraExperimental {
match crate::ios_raw_unit::IosRawUnit::start(
handler.clone(),
output_gain.clone(),
output_muted.clone(),
voice_out_tx.clone(),
transmit_flag_for_capture.clone(),
frames_sent.clone(),
mic_gain,
voice_activity_selector.clone(),
audio_processing_config.clone(),
audio_processing_stats.clone(),
) {
let raw_params = params.clone();
match crate::ios_raw_unit::IosRawUnit::start(raw_params) {
Ok(unit) => {
info!(target: "chanora_audio", "ios: RemoteIO/WebRTC APM backend selected");
return Ok(IosVoiceBackend::Raw(unit));
@@ -324,18 +380,7 @@ fn open_ios_voice_backend(
}
}
let unit = crate::ios_voice_unit::IosVoiceUnit::start(
handler,
output_gain,
output_muted,
voice_out_tx,
transmit_flag_for_capture,
frames_sent,
mic_gain,
voice_activity_selector,
audio_processing_config,
audio_processing_stats,
)?;
let unit = crate::ios_voice_unit::IosVoiceUnit::start(params)?;
Ok(IosVoiceBackend::Vpio(unit))
}
@@ -361,23 +406,40 @@ impl AudioEngine {
voice_in_rx: mpsc::Receiver<InboundVoice>,
transmit_gate: crate::ptt::AudioTransmitGate,
) -> Result<Self, AudioError> {
#[allow(clippy::needless_return)]
// Apple platforms route to a separate backend (VoiceProcessingIO
// via coreaudio-rs) because cpal does not expose the native
// voice-processing AudioUnit controls Chanora needs for VoIP.
// Windows and Linux stay on the cpal / SDL flow below.
#[cfg(any(target_os = "ios", target_os = "macos"))]
{
return Self::start_with_gate_ios(cfg, voice_out_tx, voice_in_rx, transmit_gate);
}
#[cfg(target_os = "android")]
{
return Self::start_with_gate_android(cfg, voice_out_tx, voice_in_rx, transmit_gate);
}
#[cfg(not(any(target_os = "ios", target_os = "macos", target_os = "android")))]
{
Self::start_with_gate_cpal(cfg, voice_out_tx, voice_in_rx, transmit_gate)
}
Self::start_with_gate_platform(cfg, voice_out_tx, voice_in_rx, transmit_gate)
}
// Apple platforms route to a separate backend (VoiceProcessingIO
// via coreaudio-rs) because cpal does not expose the native
// voice-processing AudioUnit controls Chanora needs for VoIP.
#[cfg(any(target_os = "ios", target_os = "macos"))]
fn start_with_gate_platform(
cfg: AudioEngineConfig,
voice_out_tx: mpsc::Sender<OutPacket>,
voice_in_rx: mpsc::Receiver<InboundVoice>,
transmit_gate: crate::ptt::AudioTransmitGate,
) -> Result<Self, AudioError> {
Self::start_with_gate_ios(cfg, voice_out_tx, voice_in_rx, transmit_gate)
}
#[cfg(target_os = "android")]
fn start_with_gate_platform(
cfg: AudioEngineConfig,
voice_out_tx: mpsc::Sender<OutPacket>,
voice_in_rx: mpsc::Receiver<InboundVoice>,
transmit_gate: crate::ptt::AudioTransmitGate,
) -> Result<Self, AudioError> {
Self::start_with_gate_android(cfg, voice_out_tx, voice_in_rx, transmit_gate)
}
#[cfg(not(any(target_os = "ios", target_os = "macos", target_os = "android")))]
fn start_with_gate_platform(
cfg: AudioEngineConfig,
voice_out_tx: mpsc::Sender<OutPacket>,
voice_in_rx: mpsc::Receiver<InboundVoice>,
transmit_gate: crate::ptt::AudioTransmitGate,
) -> Result<Self, AudioError> {
Self::start_with_gate_cpal(cfg, voice_out_tx, voice_in_rx, transmit_gate)
}
/// Non-Apple/non-Android implementation: cpal capture + (cpal | SDL2) output.
@@ -398,12 +460,45 @@ impl AudioEngine {
host_id = ?host.id(),
"starting audio engine: cpal host selected"
);
let in_dev = host
.default_input_device()
.ok_or(AudioError::NoInputDevice)?;
let out_dev = host
.default_output_device()
.ok_or(AudioError::NoOutputDevice)?;
/// Helper: find a device by name, falling back to default.
fn find_device(
host: &cpal::Host,
default_fn: fn(&cpal::Host) -> Option<cpal::Device>,
all_fn: fn(&cpal::Host) -> Result<cpal::Devices, cpal::DevicesError>,
prefer: Option<&str>,
) -> Option<cpal::Device> {
if let Some(name) = prefer {
if let Ok(devices) = all_fn(host) {
for d in devices {
let dn = d
.description()
.map(|n| n.name().to_owned())
.unwrap_or_default();
if dn == name {
return Some(d);
}
}
}
}
default_fn(host)
}
let in_dev = find_device(
&host,
cpal::Host::default_input_device,
cpal::Host::input_devices,
cfg.input_device_name.as_deref(),
)
.ok_or(AudioError::NoInputDevice)?;
let out_dev = find_device(
&host,
cpal::Host::default_output_device,
cpal::Host::output_devices,
cfg.output_device_name.as_deref(),
)
.ok_or(AudioError::NoOutputDevice)?;
info!(
target: "chanora_audio",
@@ -611,19 +706,6 @@ impl AudioEngine {
}
});
// Select and arm the desktop PTT backend is no longer the
// engine's job (SDD-088). The PTT controller lives in
// `chanora_core::ptt::PttController`; the engine is
// responsible only for the cpal streams and the
// missed-key-up watchdog (SDD-092).
// The engine no longer spawns a watchdog against the
// gate (see comment on the `ptt_watchdog` field for the
// rationale). The session spawns the watchdog against the
// selector's `ptt_held` signal instead, so it never fires
// in Continuous mode.
let ptt_watchdog: Option<crate::ptt::MissedKeyUpWatchdog> = None;
Ok(Self {
transmit_gate,
frames_sent,
@@ -632,11 +714,11 @@ impl AudioEngine {
output_muted,
audio_processing_config,
audio_processing_stats,
audio_handler,
_input_stream: Mutex::new(input_stream),
_output_stream: Mutex::new(Some(output_stream)),
shutdown_tx: Some(shutdown_tx),
capture_active,
ptt_watchdog,
})
}
@@ -716,7 +798,7 @@ impl AudioEngine {
effects: cfg.effects,
..Default::default()
};
let params = crate::android_voice_unit::VoiceAudioParams {
let params = crate::mobile_voice_backend::VoiceAudioParams {
voice_out_tx,
transmit_active: transmit_flag_for_capture,
frames_sent: frames_sent.clone(),
@@ -841,8 +923,6 @@ impl AudioEngine {
}
});
let ptt_watchdog: Option<crate::ptt::MissedKeyUpWatchdog> = None;
Ok(Self {
transmit_gate,
frames_sent,
@@ -851,11 +931,11 @@ impl AudioEngine {
output_muted,
audio_processing_config,
audio_processing_stats,
audio_handler,
_android_voice_unit: Mutex::new(Some(android_voice_unit)),
audio_mode_stack: Mutex::new(audio_mode_stack),
shutdown_tx: Some(shutdown_tx),
capture_active,
ptt_watchdog,
})
}
@@ -907,18 +987,19 @@ impl AudioEngine {
// Construct the live iOS voice backend. Platform VPIO stays
// the default shipping path; Sonora/RemoteIO remains opt-in.
let ios_voice_backend = open_ios_voice_backend(
audio_handler.clone(),
output_gain.clone(),
output_muted.clone(),
voice_out_tx_for_backend,
transmit_flag_for_capture,
frames_sent.clone(),
cfg.mic_gain,
cfg.voice_activity_selector.clone(),
audio_processing_config.clone(),
audio_processing_stats.clone(),
)?;
let ios_voice_backend =
open_ios_voice_backend(crate::mobile_voice_backend::VoiceAudioParams {
handler: audio_handler.clone(),
output_gain: output_gain.clone(),
output_muted: output_muted.clone(),
voice_out_tx: voice_out_tx_for_backend,
transmit_active: transmit_flag_for_capture,
frames_sent: frames_sent.clone(),
mic_gain: cfg.mic_gain,
voice_activity_selector: cfg.voice_activity_selector.clone(),
audio_processing_config: audio_processing_config.clone(),
audio_processing_stats: audio_processing_stats.clone(),
})?;
// Capture is always considered active on iOS — VPIO's
// input element is wired up by the AudioUnit itself, no
@@ -959,8 +1040,6 @@ impl AudioEngine {
}
});
let ptt_watchdog: Option<crate::ptt::MissedKeyUpWatchdog> = None;
Ok(Self {
transmit_gate,
frames_sent,
@@ -976,7 +1055,6 @@ impl AudioEngine {
_ios_voice_backend: Mutex::new(Some(ios_voice_backend)),
shutdown_tx: Some(shutdown_tx),
capture_active,
ptt_watchdog,
})
}
@@ -985,12 +1063,6 @@ impl AudioEngine {
if let Some(tx) = self.shutdown_tx.take() {
let _ = tx.send(());
}
// The platform PTT backend is no longer owned by the
// engine (SDD-088); its lifecycle is managed by
// `chanora_core::ptt::PttController`. The engine only
// needs to abort its watchdog and drop the audio streams.
// Aborting the watchdog cancels its tokio task.
self.ptt_watchdog.take();
// Drop the streams, which stops their callback threads.
// Each platform has a slightly different backend; the
// common contract is that dropping the wrapper stops
@@ -1088,18 +1160,18 @@ impl AudioEngine {
pub fn ios_restart_voice_unit(&self) -> Result<(), AudioError> {
#[cfg(any(target_os = "ios", target_os = "macos"))]
{
let backend = open_ios_voice_backend(
self.audio_handler.clone(),
self.output_gain.clone(),
self.output_muted.clone(),
self.voice_out_tx.clone(),
self.transmit_gate.flag_arc(),
self.frames_sent.clone(),
self.mic_gain,
self.voice_activity_selector.clone(),
self.audio_processing_config.clone(),
self.audio_processing_stats.clone(),
)?;
let backend = open_ios_voice_backend(crate::mobile_voice_backend::VoiceAudioParams {
handler: self.audio_handler.clone(),
output_gain: self.output_gain.clone(),
output_muted: self.output_muted.clone(),
voice_out_tx: self.voice_out_tx.clone(),
transmit_active: self.transmit_gate.flag_arc(),
frames_sent: self.frames_sent.clone(),
mic_gain: self.mic_gain,
voice_activity_selector: self.voice_activity_selector.clone(),
audio_processing_config: self.audio_processing_config.clone(),
audio_processing_stats: self.audio_processing_stats.clone(),
})?;
let mut guard = self._ios_voice_backend.lock().unwrap();
*guard = Some(backend);
Ok(())
@@ -1166,33 +1238,6 @@ impl AudioEngine {
&self.transmit_gate
}
/// Privacy-safe descriptor of the engine's PTT view. The
/// platform backend lives in `chanora_core::ptt::PttController`
/// (SDD-088); the engine itself no longer owns it. This getter
/// always returns the universal Focused fallback descriptor
/// and is retained only for legacy callers that constructed
/// engines directly without a controller (tests, headless
/// diagnostics).
pub fn ptt_descriptor(&self) -> crate::ptt::PttBackendDescriptor {
crate::ptt::PttBackendDescriptor::focused()
}
/// Legacy alias for [`Self::set_transmit_active`]. Retained so
/// the existing bridge `set_ptt` command and the existing
/// Flutter UI continue to compile during the v0.9.3 PTT
/// migration (SRS-201 splits the conceptual `ptt` flag into
/// `transmit_active` / `capture_active`).
#[doc(hidden)]
pub fn set_ptt(&self, active: bool) {
self.set_transmit_active(active);
}
/// Legacy alias for [`Self::transmit_active`].
#[doc(hidden)]
pub fn ptt(&self) -> bool {
self.transmit_active()
}
/// True if the capture stream opened. When false, the engine
/// runs in playback-only mode and the transmit gate is a
/// no-op (no frames will ever be encoded).
@@ -1268,6 +1313,29 @@ impl AudioEngine {
pub fn output_gain(&self) -> f32 {
f32::from_bits(self.output_gain.load(Ordering::Relaxed))
}
/// Set per-client output volume (SRS-075). `1.0` is unity, `0.0`
/// mutes. Values above `1.0` amplify and may clip. Clamped to
/// `0.0..4.0`.
pub fn set_client_volume(&self, client_id: u64, volume: f32) {
let clamped = volume.clamp(0.0, 4.0);
match self.audio_handler.lock() {
Ok(mut h) => {
if let Some(q) = h.get_mut_queues().get_mut(&SessionAudioId(client_id)) {
q.volume = clamped;
}
}
Err(e) => {
tracing::warn!(
target: "chanora_audio",
client_id,
volume = clamped,
error = %e,
"set_client_volume: audio_handler lock poisoned — volume not applied"
);
}
}
}
}
impl Drop for AudioEngine {
+52 -53
View File
@@ -44,9 +44,8 @@ mod inner {
use coreaudio::audio_unit::{AudioUnit, Element, SampleFormat, Scope, StreamFormat};
use tokio::sync::mpsc;
use tracing::{info, warn};
use tsclientlib::audio::AudioHandler;
use crate::engine::SessionAudioId;
use crate::mobile_voice_backend::VoiceAudioParams;
use crate::processor::AudioProcessor;
use crate::AudioError;
use chanora_protocol::OutPacket;
@@ -121,6 +120,7 @@ mod inner {
ten_vad_worker: Option<crate::vad::TenOnnxVadWorker>,
current_vad_backend: crate::VadBackend,
silero_model_epoch: u64,
ten_model_epoch: u64,
capture_frame_seq: u64,
vad_state: crate::voice_activity::VoiceActivityStateMachine,
/// Processing config — retained for route-change reloads.
@@ -136,18 +136,12 @@ mod inner {
impl RawCaptureState {
fn new(
voice_out_tx: mpsc::Sender<OutPacket>,
transmit_active: Arc<AtomicBool>,
output_muted: Arc<AtomicBool>,
frames_sent: Arc<AtomicU32>,
mic_gain: f32,
voice_activity_selector: Option<Arc<crate::TransmitModeSelector>>,
audio_processing_config: Arc<Mutex<crate::AudioProcessingConfig>>,
audio_processing_stats: Arc<crate::SharedAudioProcessingStats>,
params: &VoiceAudioParams,
render_reference: Arc<RenderReferenceBuffer>,
) -> Result<Self, AudioError> {
let encoder = crate::opus_voice::new_voip_encoder("ios raw")?;
let webrtc_apm_config = audio_processing_config
let webrtc_apm_config = params
.audio_processing_config
.lock()
.map(|cfg| crate::processor::webrtc_apm::WebRtcApmConfig::from_audio_config(&cfg))
.unwrap_or_default();
@@ -155,24 +149,25 @@ mod inner {
encoder,
pcm_accum: Vec::with_capacity(crate::frame::FRAME_20MS_SAMPLES * 2),
opus_out: [0u8; crate::opus_voice::MAX_OPUS_FRAME],
voice_out_tx,
transmit_active,
output_muted,
frames_sent,
mic_gain,
voice_activity_selector,
voice_out_tx: params.voice_out_tx.clone(),
transmit_active: params.transmit_active.clone(),
output_muted: params.output_muted.clone(),
frames_sent: params.frames_sent.clone(),
mic_gain: params.mic_gain,
voice_activity_selector: params.voice_activity_selector.clone(),
vad_detector: crate::vad::WebRtcFallbackVad::default(),
silero_vad_worker: None,
ten_vad_worker: None,
current_vad_backend: crate::VadBackend::WebrtcVad,
silero_model_epoch: crate::vad::silero_model_epoch(),
ten_model_epoch: crate::vad::ten_model_epoch(),
capture_frame_seq: 0,
vad_state: crate::voice_activity::VoiceActivityStateMachine::default(),
audio_processing_config,
audio_processing_config: params.audio_processing_config.clone(),
webrtc_apm_processor: crate::processor::WebRtcApmProcessor::with_config(
webrtc_apm_config,
)?,
audio_processing_stats,
audio_processing_stats: params.audio_processing_stats.clone(),
render_reference,
pending_10ms: [0_i16; crate::frame::FRAME_10MS_SAMPLES],
pending_10ms_len: 0,
@@ -184,11 +179,20 @@ mod inner {
fn mark_vad_fallback_active(&mut self, failed_backend: crate::VadBackend) {
if self.fallback_warned_backend != Some(failed_backend) {
self.fallback_warned_backend = Some(failed_backend);
tracing::warn!(
target: "chanora_audio",
backend = failed_backend.as_str(),
"VAD backend unavailable; using WebRTC fallback for runtime detection"
);
if self.capture_frame_seq < 128 {
tracing::info!(
target: "chanora_audio",
backend = failed_backend.as_str(),
seq = self.capture_frame_seq,
"VAD backend warming up; using WebRTC fallback"
);
} else {
tracing::warn!(
target: "chanora_audio",
backend = failed_backend.as_str(),
"VAD backend unavailable; using WebRTC fallback for runtime detection"
);
}
}
}
@@ -286,11 +290,15 @@ mod inner {
// Switch VAD backend when config changes.
let silero_epoch = crate::vad::silero_model_epoch();
let ten_epoch = crate::vad::ten_model_epoch();
let silero_changed = vad_backend == crate::VadBackend::SileroOnnx
&& silero_epoch != self.silero_model_epoch;
if vad_backend != self.current_vad_backend || silero_changed {
let ten_changed =
vad_backend == crate::VadBackend::TenVad && ten_epoch != self.ten_model_epoch;
if vad_backend != self.current_vad_backend || silero_changed || ten_changed {
self.current_vad_backend = vad_backend;
self.silero_model_epoch = silero_epoch;
self.ten_model_epoch = ten_epoch;
self.fallback_warned_backend = None;
match vad_backend {
crate::VadBackend::SileroOnnx => {
@@ -335,12 +343,17 @@ mod inner {
} else if vad_backend == crate::VadBackend::SileroOnnx {
if let Some(worker) = self.silero_vad_worker.as_ref() {
let enqueued = worker.try_send(capture_seq, &frame);
if enqueued && !worker.is_stale(capture_seq) {
if !worker.is_stale(capture_seq) {
let p = worker.latest_probability();
crate::vad::VadOutput {
probability: p,
speech: p >= 0.5,
}
} else if enqueued {
crate::vad::VadOutput {
probability: 0.0,
speech: false,
}
} else {
used_fallback_vad = true;
self.mark_vad_fallback_active(vad_backend);
@@ -357,12 +370,17 @@ mod inner {
} else if vad_backend == crate::VadBackend::TenVad {
if let Some(worker) = self.ten_vad_worker.as_ref() {
let enqueued = worker.try_send(capture_seq, &frame);
if enqueued && !worker.is_stale(capture_seq) {
if !worker.is_stale(capture_seq) {
let p = worker.latest_probability();
crate::vad::VadOutput {
probability: p,
speech: p >= 0.5,
}
} else if enqueued {
crate::vad::VadOutput {
probability: 0.0,
speech: false,
}
} else {
used_fallback_vad = true;
self.mark_vad_fallback_active(vad_backend);
@@ -422,22 +440,10 @@ mod inner {
impl IosRawUnit {
/// Open a RemoteIO AudioUnit, install render + input callbacks, start.
#[allow(clippy::too_many_arguments)]
pub fn start(
handler: Arc<Mutex<AudioHandler<SessionAudioId>>>,
output_gain: Arc<AtomicU32>,
output_muted: Arc<AtomicBool>,
voice_out_tx: mpsc::Sender<OutPacket>,
transmit_active: Arc<AtomicBool>,
frames_sent: Arc<AtomicU32>,
mic_gain: f32,
voice_activity_selector: Option<Arc<crate::TransmitModeSelector>>,
audio_processing_config: Arc<Mutex<crate::AudioProcessingConfig>>,
audio_processing_stats: Arc<crate::SharedAudioProcessingStats>,
) -> Result<Self, AudioError> {
pub(crate) fn start(params: VoiceAudioParams) -> Result<Self, AudioError> {
// INV_010: reject if config requests VPIO (that's IosVoiceUnit's job).
{
let cfg = audio_processing_config.lock().unwrap();
let cfg = params.audio_processing_config.lock().unwrap();
if cfg.ios_mode == crate::IosVoiceProcessingMode::PlatformVoiceProcessing {
return Err(AudioError::InvalidAudioProcessingConfig(
"IosRawUnit requires raw WebRTC APM mode".to_string(),
@@ -470,17 +476,7 @@ mod inner {
let render_ref_buf = RenderReferenceBuffer::new();
let render_ref_for_capture = render_ref_buf.clone();
let mut capture_state = RawCaptureState::new(
voice_out_tx,
transmit_active,
output_muted.clone(),
frames_sent,
mic_gain,
voice_activity_selector,
audio_processing_config,
audio_processing_stats.clone(),
render_ref_for_capture,
)?;
let mut capture_state = RawCaptureState::new(&params, render_ref_for_capture)?;
unit.set_input_callback(move |args: render_callback::Args<data::Interleaved<i16>>| {
capture_state.ingest_i16(args.data.buffer);
@@ -489,7 +485,10 @@ mod inner {
.map_err(|e| AudioError::Backend(format!("remoteio input cb: {e}")))?;
let mut scratch: Vec<f32> = Vec::with_capacity(2048);
let stats_render = audio_processing_stats.clone();
let handler = params.handler.clone();
let output_gain = params.output_gain.clone();
let output_muted = params.output_muted.clone();
let stats_render = params.audio_processing_stats.clone();
unit.set_render_callback(move |args: render_callback::Args<data::Interleaved<i16>>| {
let out = args.data.buffer;
+55 -59
View File
@@ -81,9 +81,8 @@ use coreaudio::audio_unit::IOType;
use coreaudio::audio_unit::{AudioUnit, Element, SampleFormat, Scope, StreamFormat};
use tokio::sync::mpsc;
use tracing::{debug, error, info, warn};
use tsclientlib::audio::AudioHandler;
use crate::engine::SessionAudioId;
use crate::mobile_voice_backend::VoiceAudioParams;
use crate::AudioError;
use chanora_protocol::OutPacket;
@@ -152,6 +151,8 @@ struct IosCaptureState {
current_vad_backend: crate::VadBackend,
/// Last observed configured Silero model epoch.
silero_model_epoch: u64,
/// Last observed configured TEN model epoch.
ten_model_epoch: u64,
fallback_warned_backend: Option<crate::VadBackend>,
vad_state: crate::voice_activity::VoiceActivityStateMachine,
audio_processing_config: Arc<Mutex<crate::AudioProcessingConfig>>,
@@ -172,16 +173,8 @@ impl IosCaptureState {
/// Encoder configuration is the same as cpal-side
/// `try_open_capture` (engine.rs) so audio quality is platform-
/// neutral.
#[allow(clippy::too_many_arguments)]
fn new(
voice_out_tx: mpsc::Sender<OutPacket>,
transmit_active: Arc<AtomicBool>,
output_muted: Arc<AtomicBool>,
frames_sent: Arc<AtomicU32>,
mic_gain: f32,
voice_activity_selector: Option<Arc<crate::TransmitModeSelector>>,
audio_processing_config: Arc<Mutex<crate::AudioProcessingConfig>>,
audio_processing_stats: Arc<crate::SharedAudioProcessingStats>,
params: &VoiceAudioParams,
wav_recorder: Arc<Mutex<Option<Arc<crate::debug_wav::WavDebugRecorder>>>>,
) -> Result<Self, AudioError> {
let encoder = crate::opus_voice::new_voip_encoder("ios VPIO")?;
@@ -190,22 +183,23 @@ impl IosCaptureState {
encoder,
pcm_accum: Vec::with_capacity(crate::frame::FRAME_20MS_SAMPLES * 2),
opus_out: [0u8; crate::opus_voice::MAX_OPUS_FRAME],
voice_out_tx,
transmit_active,
output_muted,
frames_sent,
mic_gain,
voice_activity_selector,
voice_out_tx: params.voice_out_tx.clone(),
transmit_active: params.transmit_active.clone(),
output_muted: params.output_muted.clone(),
frames_sent: params.frames_sent.clone(),
mic_gain: params.mic_gain,
voice_activity_selector: params.voice_activity_selector.clone(),
vad_detector: crate::vad::WebRtcFallbackVad::default(),
silero_vad_worker: None,
ten_vad: None,
current_vad_backend: crate::VadBackend::WebrtcVad,
silero_model_epoch: crate::vad::silero_model_epoch(),
ten_model_epoch: crate::vad::ten_model_epoch(),
fallback_warned_backend: None,
vad_state: crate::voice_activity::VoiceActivityStateMachine::default(),
audio_processing_config,
audio_processing_config: params.audio_processing_config.clone(),
sonora_processor: crate::processor::SonoraProcessor::new(),
audio_processing_stats,
audio_processing_stats: params.audio_processing_stats.clone(),
pending_10ms: [0_i16; crate::frame::FRAME_10MS_SAMPLES],
pending_10ms_len: 0,
pre_roll_buf: [[0_i16; crate::frame::FRAME_10MS_SAMPLES]; PRE_ROLL_FRAMES],
@@ -222,11 +216,20 @@ impl IosCaptureState {
return;
}
self.fallback_warned_backend = Some(failed_backend);
tracing::warn!(
target: "chanora_audio",
backend = failed_backend.as_str(),
"VAD backend unavailable; using WebRTC fallback for runtime detection"
);
if self.capture_frame_seq < 128 {
tracing::info!(
target: "chanora_audio",
backend = failed_backend.as_str(),
seq = self.capture_frame_seq,
"VAD backend warming up; using WebRTC fallback"
);
} else {
tracing::warn!(
target: "chanora_audio",
backend = failed_backend.as_str(),
"VAD backend unavailable; using WebRTC fallback for runtime detection"
);
}
}
/// Consume the i16 mono buffer delivered by VPIO, accumulate
@@ -360,8 +363,11 @@ impl IosCaptureState {
// Switch VAD backend when the config changes.
let silero_model_epoch = crate::vad::silero_model_epoch();
let ten_model_epoch = crate::vad::ten_model_epoch();
let silero_model_changed = vad_backend == crate::VadBackend::SileroOnnx
&& silero_model_epoch != self.silero_model_epoch;
let ten_model_changed =
vad_backend == crate::VadBackend::TenVad && ten_model_epoch != self.ten_model_epoch;
if let Ok(mut recorder_guard) = self.wav_recorder.try_lock() {
if debug_wav_dump_enabled {
@@ -376,10 +382,11 @@ impl IosCaptureState {
}
}
if vad_backend != self.current_vad_backend || silero_model_changed {
if vad_backend != self.current_vad_backend || silero_model_changed || ten_model_changed {
self.current_vad_backend = vad_backend;
self.fallback_warned_backend = None;
self.silero_model_epoch = silero_model_epoch;
self.ten_model_epoch = ten_model_epoch;
match vad_backend {
crate::VadBackend::SileroOnnx => {
// Attempt to load Silero model from the well-known
@@ -467,12 +474,17 @@ impl IosCaptureState {
} else if vad_backend == crate::VadBackend::SileroOnnx {
if let Some(worker) = self.silero_vad_worker.as_ref() {
let enqueued = worker.try_send(capture_seq, &frame);
if enqueued && !worker.is_stale(capture_seq) {
if !worker.is_stale(capture_seq) {
let probability = worker.latest_probability();
crate::vad::VadOutput {
probability,
speech: probability >= 0.5,
}
} else if enqueued {
crate::vad::VadOutput {
probability: 0.0,
speech: false,
}
} else {
used_fallback_vad = true;
self.mark_vad_fallback_active(crate::VadBackend::SileroOnnx);
@@ -486,12 +498,17 @@ impl IosCaptureState {
} else if vad_backend == crate::VadBackend::TenVad {
if let Some(worker) = self.ten_vad.as_ref() {
let enqueued = worker.try_send(capture_seq, &frame);
if enqueued && !worker.is_stale(capture_seq) {
if !worker.is_stale(capture_seq) {
let probability = worker.latest_probability();
crate::vad::VadOutput {
probability,
speech: probability >= 0.5,
}
} else if enqueued {
crate::vad::VadOutput {
probability: 0.0,
speech: false,
}
} else {
used_fallback_vad = true;
self.mark_vad_fallback_active(crate::VadBackend::TenVad);
@@ -617,19 +634,7 @@ impl IosVoiceUnit {
///
/// Capture wiring landed in commit 3; playback wiring landed
/// in commit 4. Route-change observation is commit 5.
#[allow(clippy::too_many_arguments)]
pub fn start(
handler: Arc<Mutex<AudioHandler<SessionAudioId>>>,
output_gain: Arc<AtomicU32>,
output_muted: Arc<AtomicBool>,
voice_out_tx: mpsc::Sender<OutPacket>,
transmit_active: Arc<AtomicBool>,
frames_sent: Arc<AtomicU32>,
mic_gain: f32,
voice_activity_selector: Option<Arc<crate::TransmitModeSelector>>,
audio_processing_config: Arc<Mutex<crate::AudioProcessingConfig>>,
audio_processing_stats: Arc<crate::SharedAudioProcessingStats>,
) -> Result<Self, AudioError> {
pub(crate) fn start(params: VoiceAudioParams) -> Result<Self, AudioError> {
// Construct the VoiceProcessingIO AudioUnit. cpal exposes
// `Default::default()` which on iOS picks the inferior
// RemoteIO unit; we explicitly pick VPIO. `coreaudio-rs`
@@ -721,7 +726,7 @@ impl IosVoiceUnit {
// because the input callback is the sole writer/reader on
// the audio thread.
let wav_recorder = Arc::new(Mutex::new({
let cfg = audio_processing_config.lock().unwrap().clone();
let cfg = params.audio_processing_config.lock().unwrap().clone();
if cfg.debug_wav_dump_enabled {
Some(crate::debug_wav::WavDebugRecorder::start(
cfg.route,
@@ -731,17 +736,7 @@ impl IosVoiceUnit {
None
}
}));
let mut capture_state = IosCaptureState::new(
voice_out_tx,
transmit_active,
output_muted.clone(),
frames_sent,
mic_gain,
voice_activity_selector,
audio_processing_config,
audio_processing_stats.clone(),
wav_recorder.clone(),
)?;
let mut capture_state = IosCaptureState::new(&params, wav_recorder.clone())?;
unit.set_input_callback(move |args: render_callback::Args<data::Interleaved<i16>>| {
// VPIO with our pinned stream format delivers
@@ -806,9 +801,10 @@ impl IosVoiceUnit {
// buffer. Same as Linux/SDL, just stereo-f32 -> mono-i16
// converted at the boundary.
let mut scratch_stereo: Vec<f32> = Vec::with_capacity(2048);
let handler_for_render = handler.clone();
let output_gain_for_render = output_gain.clone();
let output_muted_for_render = output_muted.clone();
let handler_for_render = params.handler.clone();
let output_gain_for_render = params.output_gain.clone();
let output_muted_for_render = params.output_muted.clone();
let audio_processing_stats_for_render = params.audio_processing_stats.clone();
let wav_recorder_for_render = wav_recorder.clone();
// Diagnostic counters (sampled every 100 callbacks ~= 2 s).
let mut cb_count: u64 = 0;
@@ -841,7 +837,7 @@ impl IosVoiceUnit {
let _removed = h.fill_buffer(&mut scratch_stereo[..needed]);
}
Err(std::sync::TryLockError::WouldBlock) => {
audio_processing_stats.increment_callback_xrun();
audio_processing_stats_for_render.increment_callback_xrun();
// scratch_stereo is already zeroed above.
}
Err(std::sync::TryLockError::Poisoned(e)) => {
@@ -859,9 +855,9 @@ impl IosVoiceUnit {
muted,
);
if mix_stats.clipped_samples > 0 {
audio_processing_stats.add_clipped_samples(mix_stats.clipped_samples);
audio_processing_stats_for_render.add_clipped_samples(mix_stats.clipped_samples);
}
audio_processing_stats.update_render(
audio_processing_stats_for_render.update_render(
crate::frame::dbfs(&scratch_stereo[..needed]),
num_frames as u32,
);
@@ -895,7 +891,7 @@ impl IosVoiceUnit {
if mix_stats.peak_i16 > 0 {
callbacks_with_audio = callbacks_with_audio.wrapping_add(1);
} else {
audio_processing_stats.increment_output_underrun();
audio_processing_stats_for_render.increment_output_underrun();
callbacks_with_silence = callbacks_with_silence.wrapping_add(1);
}
+2 -1
View File
@@ -62,7 +62,8 @@ pub use audio_processing::{
AudioBackend, AudioProcessingConfig, AudioProcessingStats, AudioRoute, EffectOwner,
IosVoiceProcessingMode, SharedAudioProcessingStats, VadBackend,
};
pub use engine::{AudioEngine, AudioEngineConfig};
pub use engine::list_audio_devices;
pub use engine::{AudioDeviceInfo, AudioDeviceList, AudioEngine, AudioEngineConfig};
// SDD-120 §3 bench seam — `#[doc(hidden)]` re-export so the criterion
// bench harness under `crates/chanora_audio/benches/` can construct a
@@ -17,13 +17,16 @@
// so the engine can hold a single `Box<dyn MobileVoiceAudioBackend>`
// across iOS and Android.
#![allow(dead_code)]
use std::fmt;
use std::sync::atomic::{AtomicBool, AtomicU32};
use std::sync::{Arc, Mutex};
use tokio::sync::mpsc;
use tsclientlib::audio::AudioHandler;
use crate::engine::SessionAudioId;
use crate::AudioEffects;
use chanora_protocol::OutPacket;
/// Events the audio-callback thread or platform JNI listener can post
/// to the tokio side of the engine (SDD-115). Audio callbacks MUST
@@ -63,6 +66,36 @@ pub type BackendEventTx = mpsc::UnboundedSender<BackendEvent>;
/// against the active capture session.
pub type AudioSessionId = i32;
/// Engine-owned state shared with mobile voice audio callbacks.
#[derive(Clone)]
pub(crate) struct VoiceAudioParams {
/// Opus-encoded voice packets sent on this channel toward the
/// protocol layer.
pub voice_out_tx: mpsc::Sender<OutPacket>,
/// PTT transmission gate — true when the user holds the PTT key.
pub transmit_active: Arc<AtomicBool>,
/// Counter incremented per encoded frame sent.
pub frames_sent: Arc<AtomicU32>,
/// Pre-encode amplitude scale (1.0 = unity).
pub mic_gain: f32,
/// AudioHandler that inbound decode+mix feeds into; the output
/// callback pulls mixed stereo f32 from it.
pub handler: Arc<Mutex<AudioHandler<SessionAudioId>>>,
/// Master output gain (f32 bits stored in AtomicU32 for lock-free
/// cross-thread read from the realtime audio callback).
pub output_gain: Arc<AtomicU32>,
/// True = output silence regardless of incoming voice frames.
pub output_muted: Arc<AtomicBool>,
/// Optional TransmitModeSelector for VoiceActivity transmit mode.
/// The capture callback calls set_voice_activity_open on this when
/// VAD detects speech. None means VoiceActivity mode is disabled.
pub voice_activity_selector: Option<Arc<crate::TransmitModeSelector>>,
/// Shared audio-processing config (WebRTC APM flags, VAD backend).
pub audio_processing_config: Arc<Mutex<crate::AudioProcessingConfig>>,
/// Shared audio-processing statistics for diagnostics.
pub audio_processing_stats: Arc<crate::SharedAudioProcessingStats>,
}
/// Achieved performance-mode reported by the platform after stream
/// open (SDD-112 item 4).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -734,12 +767,18 @@ mod tests {
/// open()/start()/stop()/close()).
#[test]
fn swe4_uv_047_backend_error_display() {
assert!(format!("{}", BackendError::OpenFailed("x".into())).contains("open failed"));
assert!(
format!("{}", BackendError::LifecycleFailed("y".into())).contains("lifecycle failed")
);
assert!(format!("{}", BackendError::ErrorDisconnected).contains("disconnected"));
assert!(format!("{}", BackendError::Platform("z".into())).contains("platform"));
assert!(BackendError::OpenFailed("x".into())
.to_string()
.contains("open failed"));
assert!(BackendError::LifecycleFailed("y".into())
.to_string()
.contains("lifecycle failed"));
assert!(BackendError::ErrorDisconnected
.to_string()
.contains("disconnected"));
assert!(BackendError::Platform("z".into())
.to_string()
.contains("platform"));
}
/// SWE4-UV-047: achieved enums Display stably (used in
@@ -748,15 +787,15 @@ mod tests {
#[test]
fn swe4_uv_047_achieved_enum_display_is_stable() {
assert_eq!(
format!("{}", AchievedPerformanceMode::LowLatency),
AchievedPerformanceMode::LowLatency.to_string(),
"LowLatency"
);
assert_eq!(
format!("{}", AchievedPerformanceMode::PowerSaving),
AchievedPerformanceMode::PowerSaving.to_string(),
"PowerSaving"
);
assert_eq!(format!("{}", AchievedPerformanceMode::None), "None");
assert_eq!(format!("{}", AchievedSharingMode::Exclusive), "Exclusive");
assert_eq!(format!("{}", AchievedSharingMode::Shared), "Shared");
assert_eq!(AchievedPerformanceMode::None.to_string(), "None");
assert_eq!(AchievedSharingMode::Exclusive.to_string(), "Exclusive");
assert_eq!(AchievedSharingMode::Shared.to_string(), "Shared");
}
}
-9
View File
@@ -136,15 +136,6 @@ impl SdlOutput {
_subsystem: subsystem,
})
}
/// Pause the SDL device. Used by the engine on hard mute /
/// shutdown if we ever want to stop the callback firing while
/// keeping the device handle alive. Not currently invoked —
/// the engine drops `SdlOutput` entirely on stop.
#[allow(dead_code)]
pub fn pause(&self) {
self.device.pause();
}
}
impl Drop for SdlOutput {
+9 -14
View File
@@ -76,6 +76,8 @@ pub struct SileroOnnxVad {
enum SileroInner {
Onnx(OnnxSession),
#[cfg(test)]
Stub,
}
struct OnnxSession {
@@ -179,9 +181,13 @@ impl SileroOnnxVad {
use ort::value::Value;
use tracing::error;
let SileroInner::Onnx(ref mut inner) = self.inner else {
return self.last_probability;
#[cfg(test)]
let inner = match self.inner {
SileroInner::Onnx(ref mut inner) => inner,
SileroInner::Stub => return self.last_probability,
};
#[cfg(not(test))]
let SileroInner::Onnx(ref mut inner) = self.inner;
debug_assert_eq!(audio_frame.len(), SILERO_FRAME_16K);
@@ -259,17 +265,6 @@ impl SileroOnnxVad {
}
}
#[cfg(target_os = "macos")]
pub(crate) fn bundled_onnxruntime_path_for_vad() -> Option<std::path::PathBuf> {
let exe = std::env::current_exe().ok()?;
let app_dir = exe.parent()?;
let framework = app_dir
.join("Frameworks")
.join("onnxruntime.framework")
.join("onnxruntime");
framework.exists().then_some(framework)
}
impl VoiceActivityDetector for SileroOnnxVad {
/// Accept one 10 ms **16 kHz** f32 mono frame (160 samples).
///
@@ -333,7 +328,7 @@ impl SileroOnnxVadWorker {
let latest_probability = Arc::new(AtomicU32::new(0.0_f32.to_bits()));
let latest_processed_seq = Arc::new(AtomicU64::new(u64::MAX));
let alive = Arc::new(AtomicBool::new(true));
let (tx, rx) = std::sync::mpsc::sync_channel::<SileroFrameMessage>(32);
let (tx, rx) = std::sync::mpsc::sync_channel::<SileroFrameMessage>(64);
let latest_probability_for_thread = latest_probability.clone();
let latest_processed_seq_for_thread = latest_processed_seq.clone();
let alive_for_thread = alive.clone();
+52 -32
View File
@@ -8,6 +8,8 @@
use crate::frame::f32_to_i16;
use rustfft::{num_complex::Complex32, FftPlanner};
use super::resampler::{Downsampler48to16, INPUT_FRAME_10MS};
use super::{VadOutput, VoiceActivityDetector};
@@ -49,6 +51,8 @@ pub struct TenOnnxVad {
feature_stack: [[f32; FEATURE_LEN]; CONTEXT],
states: [[f32; HIDDEN]; 4],
mel_filters: Vec<[f32; N_BINS]>,
fft: std::sync::Arc<dyn rustfft::Fft<f32>>,
fft_buffer: Vec<Complex32>,
last_probability: f32,
last_speech: bool,
}
@@ -75,6 +79,8 @@ impl TenOnnxVad {
return None;
}
};
let mut fft_planner = FftPlanner::<f32>::new();
let fft = fft_planner.plan_fft_forward(FFT_SIZE);
tracing::info!(target: "chanora_audio", path = model_path, "TEN VAD ONNX model loaded");
Some(Self {
session,
@@ -84,6 +90,8 @@ impl TenOnnxVad {
feature_stack: [[0.0; FEATURE_LEN]; CONTEXT],
states: [[0.0; HIDDEN]; 4],
mel_filters: build_mel_filters(),
fft,
fft_buffer: vec![Complex32::ZERO; FFT_SIZE],
last_probability: 0.0,
last_speech: false,
})
@@ -104,7 +112,12 @@ impl TenOnnxVad {
self.sample_fifo.drain(..excess);
}
let feature = compute_feature(&self.mel_filters, &frame);
let feature = compute_feature(
&self.mel_filters,
self.fft.as_ref(),
&mut self.fft_buffer,
&frame,
);
self.feature_stack.copy_within(1..CONTEXT, 0);
self.feature_stack[CONTEXT - 1] = feature;
self.run_onnx();
@@ -169,19 +182,13 @@ impl TenOnnxVad {
}
}
#[cfg(target_os = "macos")]
fn bundled_onnxruntime_path() -> Option<std::path::PathBuf> {
let exe = std::env::current_exe().ok()?;
let app_dir = exe.parent()?;
let framework = app_dir
.join("Frameworks")
.join("onnxruntime.framework")
.join("onnxruntime");
framework.exists().then_some(framework)
}
fn compute_feature(mel_filters: &[[f32; N_BINS]], frame: &[f32]) -> [f32; FEATURE_LEN] {
let power = power_spectrum(frame);
fn compute_feature(
mel_filters: &[[f32; N_BINS]],
fft: &dyn rustfft::Fft<f32>,
fft_buffer: &mut [Complex32],
frame: &[f32],
) -> [f32; FEATURE_LEN] {
let power = power_spectrum(fft, fft_buffer, frame);
let mut feature = [0.0; FEATURE_LEN];
for band in 0..MEL_BANDS {
let energy = mel_filters[band]
@@ -245,33 +252,43 @@ fn build_mel_filters() -> Vec<[f32; N_BINS]> {
let left = bins[band];
let center = bins[band + 1].max(left + 1);
let right = bins[band + 2].max(center + 1).min(N_BINS - 1);
for i in left..center.min(N_BINS) {
filters[band][i] = (i - left) as f32 / (center - left) as f32;
for (i, weight) in filters[band]
.iter_mut()
.enumerate()
.take(center.min(N_BINS))
.skip(left)
{
*weight = (i - left) as f32 / (center - left) as f32;
}
for i in center..=right {
filters[band][i] = (right - i) as f32 / (right - center).max(1) as f32;
for (i, weight) in filters[band]
.iter_mut()
.enumerate()
.take(right + 1)
.skip(center)
{
*weight = (right - i) as f32 / (right - center).max(1) as f32;
}
}
filters
}
fn power_spectrum(frame: &[f32]) -> [f32; N_BINS] {
let mut windowed = [0.0_f32; FFT_SIZE];
fn power_spectrum(
fft: &dyn rustfft::Fft<f32>,
fft_buffer: &mut [Complex32],
frame: &[f32],
) -> [f32; N_BINS] {
debug_assert_eq!(fft_buffer.len(), FFT_SIZE);
fft_buffer.fill(Complex32::ZERO);
for (idx, sample) in frame.iter().take(WINDOW_16K).enumerate() {
let hann = 0.5 - 0.5 * (2.0 * std::f32::consts::PI * idx as f32 / WINDOW_16K as f32).cos();
windowed[idx] = f32_to_i16(*sample) as f32 * hann;
fft_buffer[idx].re = f32_to_i16(*sample) as f32 * hann;
}
fft.process(fft_buffer);
let mut out = [0.0_f32; N_BINS];
for (k, dst) in out.iter_mut().enumerate() {
let mut re = 0.0_f32;
let mut im = 0.0_f32;
for (n, &x) in windowed.iter().enumerate() {
let phase = -2.0 * std::f32::consts::PI * k as f32 * n as f32 / FFT_SIZE as f32;
re += x * phase.cos();
im += x * phase.sin();
}
*dst = re * re + im * im;
for (dst, bin) in out.iter_mut().zip(fft_buffer.iter()) {
*dst = bin.norm_sqr();
}
out
}
@@ -340,7 +357,7 @@ impl TenOnnxVadWorker {
let latest_probability = Arc::new(AtomicU32::new(0.0_f32.to_bits()));
let latest_processed_seq = Arc::new(AtomicU64::new(u64::MAX));
let alive = Arc::new(AtomicBool::new(true));
let (tx, rx) = std::sync::mpsc::sync_channel::<TenFrameMessage>(32);
let (tx, rx) = std::sync::mpsc::sync_channel::<TenFrameMessage>(128);
let prob_arc = latest_probability.clone();
let seq_arc = latest_processed_seq.clone();
let alive_arc = alive.clone();
@@ -425,8 +442,11 @@ mod tests {
#[test]
fn preprocessing_produces_finite_features() {
let filters = build_mel_filters();
let mut planner = FftPlanner::<f32>::new();
let fft = planner.plan_fft_forward(FFT_SIZE);
let mut fft_buffer = vec![Complex32::ZERO; FFT_SIZE];
let frame = vec![0.0_f32; WINDOW_16K];
let feature = compute_feature(&filters, &frame);
let feature = compute_feature(&filters, fft.as_ref(), &mut fft_buffer, &frame);
assert!(feature.iter().all(|v| v.is_finite()));
}
}
-1
View File
@@ -17,7 +17,6 @@ crate-type = ["cdylib", "staticlib", "rlib"]
[dependencies]
chanora_core = { path = "../../core/chanora_core" }
chanora_protocol = { path = "../chanora_protocol" }
chanora_audio = { path = "../chanora_audio" }
flutter_rust_bridge = "=2.12.0"
thiserror.workspace = true
+249 -40
View File
@@ -80,7 +80,10 @@ fn session() -> &'static chanora_core::ChanoraSession {
fn log_sink() -> &'static chanora_core::InMemoryLogSink {
static SINK: OnceLock<chanora_core::InMemoryLogSink> = OnceLock::new();
SINK.get_or_init(|| {
chanora_core::InMemoryLogSink::new(500, chanora_core::Redactor::with_default_policy())
chanora_core::InMemoryLogSink::new(
chanora_core::DEFAULT_LOG_CAPACITY,
chanora_core::Redactor::with_default_policy(),
)
})
}
@@ -342,6 +345,9 @@ pub struct BridgeChannel {
pub order: i64,
/// True when the server marks the channel as password-protected.
pub has_password: bool,
/// Talk power threshold required to speak in this channel.
/// None means no talk-power restriction.
pub needed_talk_power: Option<i32>,
}
/// Client as seen by Dart.
@@ -361,6 +367,10 @@ pub struct BridgeClient {
pub is_speaking: bool,
/// True for TeamSpeak ServerQuery clients.
pub is_server_query: bool,
/// Current talk power value assigned by the server.
pub talk_power: i32,
/// True when the server has granted talk power regardless of numeric value.
pub talk_power_granted: bool,
}
/// Server snapshot as seen by Dart.
@@ -384,8 +394,8 @@ pub struct BridgeSnapshot {
pub own_client_id: u64,
}
impl From<chanora_protocol::ServerSnapshot> for BridgeSnapshot {
fn from(s: chanora_protocol::ServerSnapshot) -> Self {
impl From<chanora_core::ServerSnapshot> for BridgeSnapshot {
fn from(s: chanora_core::ServerSnapshot) -> Self {
Self {
server_name: s.server_name,
welcome_message: s.welcome_message,
@@ -400,6 +410,7 @@ impl From<chanora_protocol::ServerSnapshot> for BridgeSnapshot {
name: c.name,
order: c.order,
has_password: c.has_password,
needed_talk_power: c.needed_talk_power,
})
.collect(),
clients: s
@@ -413,6 +424,8 @@ impl From<chanora_protocol::ServerSnapshot> for BridgeSnapshot {
output_muted: c.output_muted,
is_speaking: c.is_speaking,
is_server_query: c.is_server_query,
talk_power: c.talk_power,
talk_power_granted: c.talk_power_granted,
})
.collect(),
own_client_id: s.own_client_id,
@@ -501,24 +514,6 @@ pub fn handle_route_change(route: BridgeAudioRoute) {
}
}
/// 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.
#[frb(sync)]
pub fn handle_media_services_reset() {
let result = runtime().block_on(async {
session()
.ios_handle_media_services_reset(chanora_audio::AudioRoute::Unknown)
.await
});
if let Err(e) = result {
warn!(target: "chanora_bridge", error = %e, "iOS media-services reset handling failed");
}
}
/// Handle iOS AVAudioSession media-services reset with the current
/// route class. Called by AppDelegate after rebuilding the session.
///
@@ -552,11 +547,11 @@ pub fn handle_interruption_ended(should_resume: bool) {
}
}
/// Set the push-to-talk state.
/// Set the focused/on-screen push-to-talk hold state.
///
/// Superseded in v1 by [`set_transmit_mode`] + the binding capture
/// dialog. Retained so legacy callers and integration tests keep
/// working; the new VoiceBar UI no longer invokes this.
/// Binding capture chooses which physical key drives PTT, while this
/// command carries the actual press/release edge for fallback focused
/// keyboard handling and touch controls.
pub async fn set_ptt(active: bool) -> Result<(), BridgeError> {
runtime()
.spawn(async move { session().set_ptt(active).await })
@@ -691,6 +686,46 @@ pub enum BridgePttInputClass {
MouseSideButton,
}
/// Privacy-safe PTT capability descriptor for the UI.
#[derive(Debug, Clone)]
pub struct BridgePttDescriptor {
/// Stable capability level name.
pub level: String,
/// Stable backend identifier.
pub backend_id: String,
/// Coarse bound input class; empty when no binding is active.
pub bound_input_class: String,
}
impl From<chanora_core::PttDescriptorSnapshot> for BridgePttDescriptor {
fn from(desc: chanora_core::PttDescriptorSnapshot) -> Self {
Self {
level: desc.level,
backend_id: desc.backend_id,
bound_input_class: desc.bound_input_class,
}
}
}
/// Persisted PTT binding display state for the UI.
#[derive(Debug, Clone)]
pub struct BridgePttBinding {
/// Stable input category string (`""`, `"keyboard"`, or
/// `"mouse-side-button"`).
pub input_class: String,
/// Display-only key label; empty when no binding is active.
pub key_label: String,
}
impl From<chanora_core::PersistedPttBinding> for BridgePttBinding {
fn from(binding: chanora_core::PersistedPttBinding) -> Self {
Self {
input_class: binding.input_class,
key_label: binding.key_label,
}
}
}
impl From<BridgePttInputClass> for chanora_core::PttInputClass {
fn from(c: BridgePttInputClass) -> Self {
match c {
@@ -721,27 +756,34 @@ pub async fn set_ptt_binding(
Ok(())
}
/// Read the current PTT capability descriptor. Returns a
/// `(level, backend_id, bound_input_class)` triple matching the
/// privacy-safe `BridgeEvent::PttCapability` event shape; useful
/// for the initial UI render before the first event arrives.
pub async fn ptt_descriptor() -> (String, String, String) {
/// Read the current PTT capability descriptor. Matches the privacy-safe
/// `BridgeEvent::PttCapability` event shape; useful for the initial UI
/// render before the first event arrives.
pub async fn ptt_descriptor() -> BridgePttDescriptor {
runtime()
.spawn(async { session().ptt_descriptor().await })
.await
.unwrap_or_else(|_| (String::new(), String::new(), String::new()))
.map(Into::into)
.unwrap_or_else(|_| BridgePttDescriptor {
level: String::new(),
backend_id: String::new(),
bound_input_class: String::new(),
})
}
/// Return the persisted PTT binding as a
/// `(input_class, platform_key)` pair so the UI can hydrate its
/// display state at launch (e.g. show "PTT: Space" next to the
/// badge before the user re-opens the binding dialog). Empty
/// strings mean no binding has been persisted yet.
pub async fn get_ptt_binding() -> (String, String) {
/// Return the persisted PTT binding so the UI can hydrate its display
/// state at launch (e.g. show "PTT: Space" next to the badge before the
/// user re-opens the binding dialog). Empty strings mean no binding has
/// been persisted yet.
pub async fn get_ptt_binding() -> BridgePttBinding {
runtime()
.spawn(async { session().get_ptt_binding().await })
.await
.unwrap_or_else(|_| (String::new(), String::new()))
.map(Into::into)
.unwrap_or_else(|_| BridgePttBinding {
input_class: String::new(),
key_label: String::new(),
})
}
/// Move our own client to `channel_id`. Optional channel password
@@ -794,6 +836,36 @@ pub async fn set_output_gain(gain: f32) -> Result<(), BridgeError> {
Ok(())
}
/// Set per-client output volume (SRS-075). `1.0` is unity, `0.0`
/// mutes. No-op when client has no active voice queue. Volume is
/// applied directly to the tsclientlib AudioQueue and takes effect
/// immediately on the next render callback.
pub async fn set_client_volume(client_id: u64, volume: f32) -> Result<(), BridgeError> {
runtime()
.spawn(async move { session().set_client_volume(client_id, volume).await })
.await
.map_err(|e| task_join_error("set_client_volume", e))??;
Ok(())
}
/// Send a text message to the specified target.
pub async fn send_chat_message(
message: String,
target: BridgeMessageTarget,
) -> Result<(), BridgeError> {
let target_core: chanora_core::MessageTarget = match target {
BridgeMessageTarget::Server => chanora_core::MessageTarget::Server,
BridgeMessageTarget::Channel => chanora_core::MessageTarget::Channel,
BridgeMessageTarget::Client(id) => chanora_core::MessageTarget::Client(id),
BridgeMessageTarget::Poke(id) => chanora_core::MessageTarget::Poke(id),
};
runtime()
.spawn(async move { session().send_text_message(message, target_core).await })
.await
.map_err(|e| task_join_error("send_chat_message", e))??;
Ok(())
}
/// Statistics from the audio engine.
#[derive(Debug, Clone)]
pub struct BridgeAudioStats {
@@ -1164,8 +1236,14 @@ pub fn export_diagnostics() -> String {
let android_audio_yaml =
chanora_audio::mobile_voice_backend::current_android_audio_diagnostics()
.map(|d| d.to_yaml_fragment());
let network_info = runtime().block_on(async { session().network_diagnostics_summary().await });
let protocol_events = runtime().block_on(async { session().drain_protocol_events().await });
match chanora_core::DiagnosticExport::from_sink(log_sink(), metadata) {
Ok(exp) => exp.with_android_audio(android_audio_yaml).to_text(),
Ok(exp) => exp
.with_android_audio(android_audio_yaml)
.with_network_info(Some(network_info))
.with_protocol_events(protocol_events)
.to_text(),
Err(e) => format!("(diagnostic export failed: {e})"),
}
}
@@ -1417,6 +1495,46 @@ pub enum BridgeEvent {
/// Resolved permission state.
state: PermissionStateKind,
},
/// A text message received from the server.
ChatMessage {
/// Client id of the sender.
sender_id: u64,
/// Nickname of the sender.
sender_name: String,
/// Message content.
message: String,
/// Target scope (server/channel/private/poke).
target: BridgeMessageTarget,
},
/// Audio route changed (speaker/earpiece/BT/wired).
AudioRouteChanged {
/// The new audio route.
route: BridgeAudioRoute,
},
}
/// Bridge message target scope.
#[derive(Debug, Clone, Copy)]
pub enum BridgeMessageTarget {
/// Broadcast to entire server.
Server,
/// Broadcast to current channel.
Channel,
/// Private message to a specific client.
Client(u64),
/// Poke a specific client.
Poke(u64),
}
impl From<chanora_core::MessageTarget> for BridgeMessageTarget {
fn from(t: chanora_core::MessageTarget) -> Self {
match t {
chanora_core::MessageTarget::Server => Self::Server,
chanora_core::MessageTarget::Channel => Self::Channel,
chanora_core::MessageTarget::Client(id) => Self::Client(id),
chanora_core::MessageTarget::Poke(id) => Self::Poke(id),
}
}
}
/// Bridge mirror of core join projection sync state.
@@ -1563,6 +1681,22 @@ impl From<chanora_core::SessionEvent> for BridgeEvent {
began,
should_resume,
},
chanora_core::SessionEvent::ChatMessage {
sender_id,
sender_name,
message,
target,
} => BridgeEvent::ChatMessage {
sender_id,
sender_name,
message,
target: target.into(),
},
chanora_core::SessionEvent::AudioRouteChanged { route } => {
BridgeEvent::AudioRouteChanged {
route: route.into(),
}
}
}
}
}
@@ -1711,6 +1845,66 @@ pub async fn audio_processing_stats() -> Result<BridgeAudioProcessingStats, Brid
Ok(stats.into())
}
/// Audio device info from the platform.
#[derive(Debug, Clone)]
pub struct BridgeAudioDevice {
/// Human-readable device name.
pub name: String,
/// True if the OS reports this as the default device.
pub is_default: bool,
}
/// List of available audio devices.
#[derive(Debug, Clone)]
pub struct BridgeAudioDeviceList {
/// Available input devices.
pub input_devices: Vec<BridgeAudioDevice>,
/// Available output devices.
pub output_devices: Vec<BridgeAudioDevice>,
}
/// List available audio input and output devices from the platform.
pub fn list_audio_devices() -> BridgeAudioDeviceList {
let list = chanora_audio::list_audio_devices();
BridgeAudioDeviceList {
input_devices: list
.input_devices
.into_iter()
.map(|d| BridgeAudioDevice {
name: d.name,
is_default: d.is_default,
})
.collect(),
output_devices: list
.output_devices
.into_iter()
.map(|d| BridgeAudioDevice {
name: d.name,
is_default: d.is_default,
})
.collect(),
}
}
/// Set the preferred input device by name. Takes effect on next
/// `start_audio`.
pub async fn set_input_device(name: Option<String>) -> Result<(), BridgeError> {
runtime()
.spawn(async move { session().set_input_device(name).await })
.await
.map_err(|e| task_join_error("set_input_device", e))??;
Ok(())
}
/// Set the preferred output device by name.
pub async fn set_output_device(name: Option<String>) -> Result<(), BridgeError> {
runtime()
.spawn(async move { session().set_output_device(name).await })
.await
.map_err(|e| task_join_error("set_output_device", e))??;
Ok(())
}
/// Configure the VAD model path.
pub async fn set_vad_model_path(path: String) -> Result<(), BridgeError> {
if path.trim().is_empty() {
@@ -1733,7 +1927,7 @@ pub async fn set_ten_vad_model_path(path: String) -> Result<(), BridgeError> {
));
}
runtime()
.spawn(async move { chanora_audio::vad::set_ten_model_path(&path).map_err(|e| e) })
.spawn(async move { chanora_audio::vad::set_ten_model_path(&path) })
.await
.map_err(|e| task_join_error("set_ten_vad_model_path", e))?
.map_err(|e| BridgeError::Unmapped(format!("set_ten_vad_model_path: {e}")))?;
@@ -1785,3 +1979,18 @@ pub async fn set_ios_voice_processing_mode(
};
set_audio_processing_config(config).await
}
/// Set the preferred audio output route (Android/iOS).
#[frb(sync)]
pub fn set_audio_output_route(route: BridgeAudioRoute) {
runtime().block_on(async {
let _ = session().ios_handle_route_change(route.into()).await;
});
}
/// Called from Flutter when the app enters background/foreground.
#[frb(sync)]
pub fn record_lifecycle_event(state: String) {
runtime().block_on(async {
session().record_lifecycle_event(&state).await;
});
}
+639 -90
View File
@@ -38,7 +38,7 @@ flutter_rust_bridge::frb_generated_boilerplate!(
default_rust_auto_opaque = RustAutoOpaqueMoi,
);
pub(crate) const FLUTTER_RUST_BRIDGE_CODEGEN_VERSION: &str = "2.12.0";
pub(crate) const FLUTTER_RUST_BRIDGE_CODEGEN_CONTENT_HASH: i32 = -436507436;
pub(crate) const FLUTTER_RUST_BRIDGE_CODEGEN_CONTENT_HASH: i32 = 1433826599;
// Section: executor
@@ -602,37 +602,6 @@ fn wire__crate__api__handle_interruption_ended_impl(
},
)
}
fn wire__crate__api__handle_media_services_reset_impl(
ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr,
rust_vec_len_: i32,
data_len_: i32,
) -> flutter_rust_bridge::for_generated::WireSyncRust2DartSse {
FLUTTER_RUST_BRIDGE_HANDLER.wrap_sync::<flutter_rust_bridge::for_generated::SseCodec, _>(
flutter_rust_bridge::for_generated::TaskInfo {
debug_name: "handle_media_services_reset",
port: None,
mode: flutter_rust_bridge::for_generated::FfiCallMode::Sync,
},
move || {
let message = unsafe {
flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire(
ptr_,
rust_vec_len_,
data_len_,
)
};
let mut deserializer =
flutter_rust_bridge::for_generated::SseDeserializer::new(message);
deserializer.end();
transform_result_sse::<_, ()>((move || {
let output_ok = Result::<_, ()>::Ok({
crate::api::handle_media_services_reset();
})?;
Ok(output_ok)
})())
},
)
}
fn wire__crate__api__handle_media_services_reset_with_route_impl(
ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr,
rust_vec_len_: i32,
@@ -768,6 +737,38 @@ fn wire__crate__api__is_connected_impl(
},
)
}
fn wire__crate__api__list_audio_devices_impl(
port_: flutter_rust_bridge::for_generated::MessagePort,
ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr,
rust_vec_len_: i32,
data_len_: i32,
) {
FLUTTER_RUST_BRIDGE_HANDLER.wrap_normal::<flutter_rust_bridge::for_generated::SseCodec, _, _>(
flutter_rust_bridge::for_generated::TaskInfo {
debug_name: "list_audio_devices",
port: Some(port_),
mode: flutter_rust_bridge::for_generated::FfiCallMode::Normal,
},
move || {
let message = unsafe {
flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire(
ptr_,
rust_vec_len_,
data_len_,
)
};
let mut deserializer =
flutter_rust_bridge::for_generated::SseDeserializer::new(message);
deserializer.end();
move |context| {
transform_result_sse::<_, ()>((move || {
let output_ok = Result::<_, ()>::Ok(crate::api::list_audio_devices())?;
Ok(output_ok)
})())
}
},
)
}
fn wire__crate__api__list_bookmarks_impl(
port_: flutter_rust_bridge::for_generated::MessagePort,
ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr,
@@ -905,6 +906,108 @@ fn wire__crate__api__ptt_descriptor_impl(
},
)
}
fn wire__crate__api__record_lifecycle_event_impl(
ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr,
rust_vec_len_: i32,
data_len_: i32,
) -> flutter_rust_bridge::for_generated::WireSyncRust2DartSse {
FLUTTER_RUST_BRIDGE_HANDLER.wrap_sync::<flutter_rust_bridge::for_generated::SseCodec, _>(
flutter_rust_bridge::for_generated::TaskInfo {
debug_name: "record_lifecycle_event",
port: None,
mode: flutter_rust_bridge::for_generated::FfiCallMode::Sync,
},
move || {
let message = unsafe {
flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire(
ptr_,
rust_vec_len_,
data_len_,
)
};
let mut deserializer =
flutter_rust_bridge::for_generated::SseDeserializer::new(message);
let api_state = <String>::sse_decode(&mut deserializer);
deserializer.end();
transform_result_sse::<_, ()>((move || {
let output_ok = Result::<_, ()>::Ok({
crate::api::record_lifecycle_event(api_state);
})?;
Ok(output_ok)
})())
},
)
}
fn wire__crate__api__send_chat_message_impl(
port_: flutter_rust_bridge::for_generated::MessagePort,
ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr,
rust_vec_len_: i32,
data_len_: i32,
) {
FLUTTER_RUST_BRIDGE_HANDLER.wrap_async::<flutter_rust_bridge::for_generated::SseCodec, _, _, _>(
flutter_rust_bridge::for_generated::TaskInfo {
debug_name: "send_chat_message",
port: Some(port_),
mode: flutter_rust_bridge::for_generated::FfiCallMode::Normal,
},
move || {
let message = unsafe {
flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire(
ptr_,
rust_vec_len_,
data_len_,
)
};
let mut deserializer =
flutter_rust_bridge::for_generated::SseDeserializer::new(message);
let api_message = <String>::sse_decode(&mut deserializer);
let api_target = <crate::api::BridgeMessageTarget>::sse_decode(&mut deserializer);
deserializer.end();
move |context| async move {
transform_result_sse::<_, crate::BridgeError>(
(move || async move {
let output_ok =
crate::api::send_chat_message(api_message, api_target).await?;
Ok(output_ok)
})()
.await,
)
}
},
)
}
fn wire__crate__api__set_audio_output_route_impl(
ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr,
rust_vec_len_: i32,
data_len_: i32,
) -> flutter_rust_bridge::for_generated::WireSyncRust2DartSse {
FLUTTER_RUST_BRIDGE_HANDLER.wrap_sync::<flutter_rust_bridge::for_generated::SseCodec, _>(
flutter_rust_bridge::for_generated::TaskInfo {
debug_name: "set_audio_output_route",
port: None,
mode: flutter_rust_bridge::for_generated::FfiCallMode::Sync,
},
move || {
let message = unsafe {
flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire(
ptr_,
rust_vec_len_,
data_len_,
)
};
let mut deserializer =
flutter_rust_bridge::for_generated::SseDeserializer::new(message);
let api_route = <crate::api::BridgeAudioRoute>::sse_decode(&mut deserializer);
deserializer.end();
transform_result_sse::<_, ()>((move || {
let output_ok = Result::<_, ()>::Ok({
crate::api::set_audio_output_route(api_route);
})?;
Ok(output_ok)
})())
},
)
}
fn wire__crate__api__set_audio_processing_config_impl(
port_: flutter_rust_bridge::for_generated::MessagePort,
ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr,
@@ -942,6 +1045,44 @@ fn wire__crate__api__set_audio_processing_config_impl(
},
)
}
fn wire__crate__api__set_client_volume_impl(
port_: flutter_rust_bridge::for_generated::MessagePort,
ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr,
rust_vec_len_: i32,
data_len_: i32,
) {
FLUTTER_RUST_BRIDGE_HANDLER.wrap_async::<flutter_rust_bridge::for_generated::SseCodec, _, _, _>(
flutter_rust_bridge::for_generated::TaskInfo {
debug_name: "set_client_volume",
port: Some(port_),
mode: flutter_rust_bridge::for_generated::FfiCallMode::Normal,
},
move || {
let message = unsafe {
flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire(
ptr_,
rust_vec_len_,
data_len_,
)
};
let mut deserializer =
flutter_rust_bridge::for_generated::SseDeserializer::new(message);
let api_client_id = <u64>::sse_decode(&mut deserializer);
let api_volume = <f32>::sse_decode(&mut deserializer);
deserializer.end();
move |context| async move {
transform_result_sse::<_, crate::BridgeError>(
(move || async move {
let output_ok =
crate::api::set_client_volume(api_client_id, api_volume).await?;
Ok(output_ok)
})()
.await,
)
}
},
)
}
fn wire__crate__api__set_hard_mute_impl(
port_: flutter_rust_bridge::for_generated::MessagePort,
ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr,
@@ -978,6 +1119,42 @@ fn wire__crate__api__set_hard_mute_impl(
},
)
}
fn wire__crate__api__set_input_device_impl(
port_: flutter_rust_bridge::for_generated::MessagePort,
ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr,
rust_vec_len_: i32,
data_len_: i32,
) {
FLUTTER_RUST_BRIDGE_HANDLER.wrap_async::<flutter_rust_bridge::for_generated::SseCodec, _, _, _>(
flutter_rust_bridge::for_generated::TaskInfo {
debug_name: "set_input_device",
port: Some(port_),
mode: flutter_rust_bridge::for_generated::FfiCallMode::Normal,
},
move || {
let message = unsafe {
flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire(
ptr_,
rust_vec_len_,
data_len_,
)
};
let mut deserializer =
flutter_rust_bridge::for_generated::SseDeserializer::new(message);
let api_name = <Option<String>>::sse_decode(&mut deserializer);
deserializer.end();
move |context| async move {
transform_result_sse::<_, crate::BridgeError>(
(move || async move {
let output_ok = crate::api::set_input_device(api_name).await?;
Ok(output_ok)
})()
.await,
)
}
},
)
}
fn wire__crate__api__set_input_muted_impl(
port_: flutter_rust_bridge::for_generated::MessagePort,
ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr,
@@ -1083,6 +1260,42 @@ fn wire__crate__api__set_network_state_impl(
},
)
}
fn wire__crate__api__set_output_device_impl(
port_: flutter_rust_bridge::for_generated::MessagePort,
ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr,
rust_vec_len_: i32,
data_len_: i32,
) {
FLUTTER_RUST_BRIDGE_HANDLER.wrap_async::<flutter_rust_bridge::for_generated::SseCodec, _, _, _>(
flutter_rust_bridge::for_generated::TaskInfo {
debug_name: "set_output_device",
port: Some(port_),
mode: flutter_rust_bridge::for_generated::FfiCallMode::Normal,
},
move || {
let message = unsafe {
flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire(
ptr_,
rust_vec_len_,
data_len_,
)
};
let mut deserializer =
flutter_rust_bridge::for_generated::SseDeserializer::new(message);
let api_name = <Option<String>>::sse_decode(&mut deserializer);
deserializer.end();
move |context| async move {
transform_result_sse::<_, crate::BridgeError>(
(move || async move {
let output_ok = crate::api::set_output_device(api_name).await?;
Ok(output_ok)
})()
.await,
)
}
},
)
}
fn wire__crate__api__set_output_gain_impl(
port_: flutter_rust_bridge::for_generated::MessagePort,
ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr,
@@ -1567,6 +1780,30 @@ impl SseDecode for crate::api::BridgeAudioBackend {
}
}
impl SseDecode for crate::api::BridgeAudioDevice {
// Codec=Sse (Serialization based), see doc to use other codecs
fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self {
let mut var_name = <String>::sse_decode(deserializer);
let mut var_isDefault = <bool>::sse_decode(deserializer);
return crate::api::BridgeAudioDevice {
name: var_name,
is_default: var_isDefault,
};
}
}
impl SseDecode for crate::api::BridgeAudioDeviceList {
// Codec=Sse (Serialization based), see doc to use other codecs
fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self {
let mut var_inputDevices = <Vec<crate::api::BridgeAudioDevice>>::sse_decode(deserializer);
let mut var_outputDevices = <Vec<crate::api::BridgeAudioDevice>>::sse_decode(deserializer);
return crate::api::BridgeAudioDeviceList {
input_devices: var_inputDevices,
output_devices: var_outputDevices,
};
}
}
impl SseDecode for crate::api::BridgeAudioProcessingConfig {
// Codec=Sse (Serialization based), see doc to use other codecs
fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self {
@@ -1704,12 +1941,14 @@ impl SseDecode for crate::api::BridgeChannel {
let mut var_name = <String>::sse_decode(deserializer);
let mut var_order = <i64>::sse_decode(deserializer);
let mut var_hasPassword = <bool>::sse_decode(deserializer);
let mut var_neededTalkPower = <Option<i32>>::sse_decode(deserializer);
return crate::api::BridgeChannel {
id: var_id,
parent: var_parent,
name: var_name,
order: var_order,
has_password: var_hasPassword,
needed_talk_power: var_neededTalkPower,
};
}
}
@@ -1724,6 +1963,8 @@ impl SseDecode for crate::api::BridgeClient {
let mut var_outputMuted = <bool>::sse_decode(deserializer);
let mut var_isSpeaking = <bool>::sse_decode(deserializer);
let mut var_isServerQuery = <bool>::sse_decode(deserializer);
let mut var_talkPower = <i32>::sse_decode(deserializer);
let mut var_talkPowerGranted = <bool>::sse_decode(deserializer);
return crate::api::BridgeClient {
id: var_id,
channel: var_channel,
@@ -1732,6 +1973,8 @@ impl SseDecode for crate::api::BridgeClient {
output_muted: var_outputMuted,
is_speaking: var_isSpeaking,
is_server_query: var_isServerQuery,
talk_power: var_talkPower,
talk_power_granted: var_talkPowerGranted,
};
}
}
@@ -1891,6 +2134,22 @@ impl SseDecode for crate::api::BridgeEvent {
state: var_state,
};
}
11 => {
let mut var_senderId = <u64>::sse_decode(deserializer);
let mut var_senderName = <String>::sse_decode(deserializer);
let mut var_message = <String>::sse_decode(deserializer);
let mut var_target = <crate::api::BridgeMessageTarget>::sse_decode(deserializer);
return crate::api::BridgeEvent::ChatMessage {
sender_id: var_senderId,
sender_name: var_senderName,
message: var_message,
target: var_target,
};
}
12 => {
let mut var_route = <crate::api::BridgeAudioRoute>::sse_decode(deserializer);
return crate::api::BridgeEvent::AudioRouteChanged { route: var_route };
}
_ => {
unimplemented!("");
}
@@ -1913,6 +2172,32 @@ impl SseDecode for crate::api::BridgeIosVoiceProcessingMode {
}
}
impl SseDecode for crate::api::BridgeMessageTarget {
// Codec=Sse (Serialization based), see doc to use other codecs
fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self {
let mut tag_ = <i32>::sse_decode(deserializer);
match tag_ {
0 => {
return crate::api::BridgeMessageTarget::Server;
}
1 => {
return crate::api::BridgeMessageTarget::Channel;
}
2 => {
let mut var_field0 = <u64>::sse_decode(deserializer);
return crate::api::BridgeMessageTarget::Client(var_field0);
}
3 => {
let mut var_field0 = <u64>::sse_decode(deserializer);
return crate::api::BridgeMessageTarget::Poke(var_field0);
}
_ => {
unimplemented!("");
}
}
}
}
impl SseDecode for crate::api::BridgeNetworkState {
// Codec=Sse (Serialization based), see doc to use other codecs
fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self {
@@ -1926,6 +2211,32 @@ impl SseDecode for crate::api::BridgeNetworkState {
}
}
impl SseDecode for crate::api::BridgePttBinding {
// Codec=Sse (Serialization based), see doc to use other codecs
fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self {
let mut var_inputClass = <String>::sse_decode(deserializer);
let mut var_keyLabel = <String>::sse_decode(deserializer);
return crate::api::BridgePttBinding {
input_class: var_inputClass,
key_label: var_keyLabel,
};
}
}
impl SseDecode for crate::api::BridgePttDescriptor {
// Codec=Sse (Serialization based), see doc to use other codecs
fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self {
let mut var_level = <String>::sse_decode(deserializer);
let mut var_backendId = <String>::sse_decode(deserializer);
let mut var_boundInputClass = <String>::sse_decode(deserializer);
return crate::api::BridgePttDescriptor {
level: var_level,
backend_id: var_backendId,
bound_input_class: var_boundInputClass,
};
}
}
impl SseDecode for crate::api::BridgePttInputClass {
// Codec=Sse (Serialization based), see doc to use other codecs
fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self {
@@ -2044,6 +2355,18 @@ impl SseDecode for i64 {
}
}
impl SseDecode for Vec<crate::api::BridgeAudioDevice> {
// Codec=Sse (Serialization based), see doc to use other codecs
fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self {
let mut len_ = <i32>::sse_decode(deserializer);
let mut ans_ = Vec::with_capacity(len_ as usize);
for idx_ in 0..len_ {
ans_.push(<crate::api::BridgeAudioDevice>::sse_decode(deserializer));
}
return ans_;
}
}
impl SseDecode for Vec<crate::api::BridgeBookmark> {
// Codec=Sse (Serialization based), see doc to use other codecs
fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self {
@@ -2092,6 +2415,17 @@ impl SseDecode for Vec<u8> {
}
}
impl SseDecode for Option<String> {
// Codec=Sse (Serialization based), see doc to use other codecs
fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self {
if (<bool>::sse_decode(deserializer)) {
return Some(<String>::sse_decode(deserializer));
} else {
return None;
}
}
}
impl SseDecode for Option<crate::api::BridgeVoiceJoinErrorCode> {
// Codec=Sse (Serialization based), see doc to use other codecs
fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self {
@@ -2105,6 +2439,17 @@ impl SseDecode for Option<crate::api::BridgeVoiceJoinErrorCode> {
}
}
impl SseDecode for Option<i32> {
// Codec=Sse (Serialization based), see doc to use other codecs
fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self {
if (<bool>::sse_decode(deserializer)) {
return Some(<i32>::sse_decode(deserializer));
} else {
return None;
}
}
}
impl SseDecode for Option<u64> {
// Codec=Sse (Serialization based), see doc to use other codecs
fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self {
@@ -2130,25 +2475,6 @@ impl SseDecode for crate::api::PermissionStateKind {
}
}
impl SseDecode for (String, String) {
// Codec=Sse (Serialization based), see doc to use other codecs
fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self {
let mut var_field0 = <String>::sse_decode(deserializer);
let mut var_field1 = <String>::sse_decode(deserializer);
return (var_field0, var_field1);
}
}
impl SseDecode for (String, String, String) {
// Codec=Sse (Serialization based), see doc to use other codecs
fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self {
let mut var_field0 = <String>::sse_decode(deserializer);
let mut var_field1 = <String>::sse_decode(deserializer);
let mut var_field2 = <String>::sse_decode(deserializer);
return (var_field0, var_field1, var_field2);
}
}
impl SseDecode for u32 {
// Codec=Sse (Serialization based), see doc to use other codecs
fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self {
@@ -2197,29 +2523,34 @@ fn pde_ffi_dispatcher_primary_impl(
12 => wire__crate__api__get_ptt_binding_impl(port, ptr, rust_vec_len, data_len),
13 => wire__crate__api__get_release_tail_ms_impl(port, ptr, rust_vec_len, data_len),
14 => wire__crate__api__get_transmit_mode_impl(port, ptr, rust_vec_len, data_len),
20 => wire__crate__api__init_storage_impl(port, ptr, rust_vec_len, data_len),
21 => wire__crate__api__is_connected_impl(port, ptr, rust_vec_len, data_len),
19 => wire__crate__api__init_storage_impl(port, ptr, rust_vec_len, data_len),
20 => wire__crate__api__is_connected_impl(port, ptr, rust_vec_len, data_len),
21 => wire__crate__api__list_audio_devices_impl(port, ptr, rust_vec_len, data_len),
22 => wire__crate__api__list_bookmarks_impl(port, ptr, rust_vec_len, data_len),
24 => wire__crate__api__move_to_channel_impl(port, ptr, rust_vec_len, data_len),
25 => wire__crate__api__ptt_descriptor_impl(port, ptr, rust_vec_len, data_len),
26 => wire__crate__api__set_audio_processing_config_impl(port, ptr, rust_vec_len, data_len),
27 => wire__crate__api__set_hard_mute_impl(port, ptr, rust_vec_len, data_len),
28 => wire__crate__api__set_input_muted_impl(port, ptr, rust_vec_len, data_len),
29 => {
27 => wire__crate__api__send_chat_message_impl(port, ptr, rust_vec_len, data_len),
29 => wire__crate__api__set_audio_processing_config_impl(port, ptr, rust_vec_len, data_len),
30 => wire__crate__api__set_client_volume_impl(port, ptr, rust_vec_len, data_len),
31 => wire__crate__api__set_hard_mute_impl(port, ptr, rust_vec_len, data_len),
32 => wire__crate__api__set_input_device_impl(port, ptr, rust_vec_len, data_len),
33 => wire__crate__api__set_input_muted_impl(port, ptr, rust_vec_len, data_len),
34 => {
wire__crate__api__set_ios_voice_processing_mode_impl(port, ptr, rust_vec_len, data_len)
}
31 => wire__crate__api__set_output_gain_impl(port, ptr, rust_vec_len, data_len),
32 => wire__crate__api__set_output_muted_impl(port, ptr, rust_vec_len, data_len),
33 => wire__crate__api__set_ptt_impl(port, ptr, rust_vec_len, data_len),
34 => wire__crate__api__set_ptt_binding_impl(port, ptr, rust_vec_len, data_len),
35 => wire__crate__api__set_release_tail_ms_impl(port, ptr, rust_vec_len, data_len),
36 => wire__crate__api__set_ten_vad_model_path_impl(port, ptr, rust_vec_len, data_len),
37 => wire__crate__api__set_transmit_mode_impl(port, ptr, rust_vec_len, data_len),
38 => wire__crate__api__set_vad_model_path_impl(port, ptr, rust_vec_len, data_len),
39 => wire__crate__api__snapshot_impl(port, ptr, rust_vec_len, data_len),
40 => wire__crate__api__update_bookmark_impl(port, ptr, rust_vec_len, data_len),
41 => wire__crate__api__voice_join_impl(port, ptr, rust_vec_len, data_len),
42 => wire__crate__api__voice_leave_impl(port, ptr, rust_vec_len, data_len),
36 => wire__crate__api__set_output_device_impl(port, ptr, rust_vec_len, data_len),
37 => wire__crate__api__set_output_gain_impl(port, ptr, rust_vec_len, data_len),
38 => wire__crate__api__set_output_muted_impl(port, ptr, rust_vec_len, data_len),
39 => wire__crate__api__set_ptt_impl(port, ptr, rust_vec_len, data_len),
40 => wire__crate__api__set_ptt_binding_impl(port, ptr, rust_vec_len, data_len),
41 => wire__crate__api__set_release_tail_ms_impl(port, ptr, rust_vec_len, data_len),
42 => wire__crate__api__set_ten_vad_model_path_impl(port, ptr, rust_vec_len, data_len),
43 => wire__crate__api__set_transmit_mode_impl(port, ptr, rust_vec_len, data_len),
44 => wire__crate__api__set_vad_model_path_impl(port, ptr, rust_vec_len, data_len),
45 => wire__crate__api__snapshot_impl(port, ptr, rust_vec_len, data_len),
46 => wire__crate__api__update_bookmark_impl(port, ptr, rust_vec_len, data_len),
47 => wire__crate__api__voice_join_impl(port, ptr, rust_vec_len, data_len),
48 => wire__crate__api__voice_leave_impl(port, ptr, rust_vec_len, data_len),
_ => unreachable!(),
}
}
@@ -2235,15 +2566,16 @@ fn pde_ffi_dispatcher_sync_impl(
10 => wire__crate__api__export_diagnostics_impl(ptr, rust_vec_len, data_len),
15 => wire__crate__api__handle_interruption_began_impl(ptr, rust_vec_len, data_len),
16 => wire__crate__api__handle_interruption_ended_impl(ptr, rust_vec_len, data_len),
17 => wire__crate__api__handle_media_services_reset_impl(ptr, rust_vec_len, data_len),
18 => wire__crate__api__handle_media_services_reset_with_route_impl(
17 => wire__crate__api__handle_media_services_reset_with_route_impl(
ptr,
rust_vec_len,
data_len,
),
19 => wire__crate__api__handle_route_change_impl(ptr, rust_vec_len, data_len),
18 => wire__crate__api__handle_route_change_impl(ptr, rust_vec_len, data_len),
23 => wire__crate__api__log_file_path_str_impl(ptr, rust_vec_len, data_len),
30 => wire__crate__api__set_network_state_impl(ptr, rust_vec_len, data_len),
26 => wire__crate__api__record_lifecycle_event_impl(ptr, rust_vec_len, data_len),
28 => wire__crate__api__set_audio_output_route_impl(ptr, rust_vec_len, data_len),
35 => wire__crate__api__set_network_state_impl(ptr, rust_vec_len, data_len),
_ => unreachable!(),
}
}
@@ -2274,6 +2606,45 @@ impl flutter_rust_bridge::IntoIntoDart<crate::api::BridgeAudioBackend>
}
}
// Codec=Dco (DartCObject based), see doc to use other codecs
impl flutter_rust_bridge::IntoDart for crate::api::BridgeAudioDevice {
fn into_dart(self) -> flutter_rust_bridge::for_generated::DartAbi {
[
self.name.into_into_dart().into_dart(),
self.is_default.into_into_dart().into_dart(),
]
.into_dart()
}
}
impl flutter_rust_bridge::for_generated::IntoDartExceptPrimitive for crate::api::BridgeAudioDevice {}
impl flutter_rust_bridge::IntoIntoDart<crate::api::BridgeAudioDevice>
for crate::api::BridgeAudioDevice
{
fn into_into_dart(self) -> crate::api::BridgeAudioDevice {
self
}
}
// Codec=Dco (DartCObject based), see doc to use other codecs
impl flutter_rust_bridge::IntoDart for crate::api::BridgeAudioDeviceList {
fn into_dart(self) -> flutter_rust_bridge::for_generated::DartAbi {
[
self.input_devices.into_into_dart().into_dart(),
self.output_devices.into_into_dart().into_dart(),
]
.into_dart()
}
}
impl flutter_rust_bridge::for_generated::IntoDartExceptPrimitive
for crate::api::BridgeAudioDeviceList
{
}
impl flutter_rust_bridge::IntoIntoDart<crate::api::BridgeAudioDeviceList>
for crate::api::BridgeAudioDeviceList
{
fn into_into_dart(self) -> crate::api::BridgeAudioDeviceList {
self
}
}
// Codec=Dco (DartCObject based), see doc to use other codecs
impl flutter_rust_bridge::IntoDart for crate::api::BridgeAudioProcessingConfig {
fn into_dart(self) -> flutter_rust_bridge::for_generated::DartAbi {
[
@@ -2414,6 +2785,7 @@ impl flutter_rust_bridge::IntoDart for crate::api::BridgeChannel {
self.name.into_into_dart().into_dart(),
self.order.into_into_dart().into_dart(),
self.has_password.into_into_dart().into_dart(),
self.needed_talk_power.into_into_dart().into_dart(),
]
.into_dart()
}
@@ -2435,6 +2807,8 @@ impl flutter_rust_bridge::IntoDart for crate::api::BridgeClient {
self.output_muted.into_into_dart().into_dart(),
self.is_speaking.into_into_dart().into_dart(),
self.is_server_query.into_into_dart().into_dart(),
self.talk_power.into_into_dart().into_dart(),
self.talk_power_granted.into_into_dart().into_dart(),
]
.into_dart()
}
@@ -2586,6 +2960,22 @@ impl flutter_rust_bridge::IntoDart for crate::api::BridgeEvent {
state.into_into_dart().into_dart(),
]
.into_dart(),
crate::api::BridgeEvent::ChatMessage {
sender_id,
sender_name,
message,
target,
} => [
11.into_dart(),
sender_id.into_into_dart().into_dart(),
sender_name.into_into_dart().into_dart(),
message.into_into_dart().into_dart(),
target.into_into_dart().into_dart(),
]
.into_dart(),
crate::api::BridgeEvent::AudioRouteChanged { route } => {
[12.into_dart(), route.into_into_dart().into_dart()].into_dart()
}
_ => {
unimplemented!("");
}
@@ -2620,6 +3010,35 @@ impl flutter_rust_bridge::IntoIntoDart<crate::api::BridgeIosVoiceProcessingMode>
}
}
// Codec=Dco (DartCObject based), see doc to use other codecs
impl flutter_rust_bridge::IntoDart for crate::api::BridgeMessageTarget {
fn into_dart(self) -> flutter_rust_bridge::for_generated::DartAbi {
match self {
crate::api::BridgeMessageTarget::Server => [0.into_dart()].into_dart(),
crate::api::BridgeMessageTarget::Channel => [1.into_dart()].into_dart(),
crate::api::BridgeMessageTarget::Client(field0) => {
[2.into_dart(), field0.into_into_dart().into_dart()].into_dart()
}
crate::api::BridgeMessageTarget::Poke(field0) => {
[3.into_dart(), field0.into_into_dart().into_dart()].into_dart()
}
_ => {
unimplemented!("");
}
}
}
}
impl flutter_rust_bridge::for_generated::IntoDartExceptPrimitive
for crate::api::BridgeMessageTarget
{
}
impl flutter_rust_bridge::IntoIntoDart<crate::api::BridgeMessageTarget>
for crate::api::BridgeMessageTarget
{
fn into_into_dart(self) -> crate::api::BridgeMessageTarget {
self
}
}
// Codec=Dco (DartCObject based), see doc to use other codecs
impl flutter_rust_bridge::IntoDart for crate::api::BridgeNetworkState {
fn into_dart(self) -> flutter_rust_bridge::for_generated::DartAbi {
match self {
@@ -2642,6 +3061,46 @@ impl flutter_rust_bridge::IntoIntoDart<crate::api::BridgeNetworkState>
}
}
// Codec=Dco (DartCObject based), see doc to use other codecs
impl flutter_rust_bridge::IntoDart for crate::api::BridgePttBinding {
fn into_dart(self) -> flutter_rust_bridge::for_generated::DartAbi {
[
self.input_class.into_into_dart().into_dart(),
self.key_label.into_into_dart().into_dart(),
]
.into_dart()
}
}
impl flutter_rust_bridge::for_generated::IntoDartExceptPrimitive for crate::api::BridgePttBinding {}
impl flutter_rust_bridge::IntoIntoDart<crate::api::BridgePttBinding>
for crate::api::BridgePttBinding
{
fn into_into_dart(self) -> crate::api::BridgePttBinding {
self
}
}
// Codec=Dco (DartCObject based), see doc to use other codecs
impl flutter_rust_bridge::IntoDart for crate::api::BridgePttDescriptor {
fn into_dart(self) -> flutter_rust_bridge::for_generated::DartAbi {
[
self.level.into_into_dart().into_dart(),
self.backend_id.into_into_dart().into_dart(),
self.bound_input_class.into_into_dart().into_dart(),
]
.into_dart()
}
}
impl flutter_rust_bridge::for_generated::IntoDartExceptPrimitive
for crate::api::BridgePttDescriptor
{
}
impl flutter_rust_bridge::IntoIntoDart<crate::api::BridgePttDescriptor>
for crate::api::BridgePttDescriptor
{
fn into_into_dart(self) -> crate::api::BridgePttDescriptor {
self
}
}
// Codec=Dco (DartCObject based), see doc to use other codecs
impl flutter_rust_bridge::IntoDart for crate::api::BridgePttInputClass {
fn into_dart(self) -> flutter_rust_bridge::for_generated::DartAbi {
match self {
@@ -2851,6 +3310,22 @@ impl SseEncode for crate::api::BridgeAudioBackend {
}
}
impl SseEncode for crate::api::BridgeAudioDevice {
// Codec=Sse (Serialization based), see doc to use other codecs
fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) {
<String>::sse_encode(self.name, serializer);
<bool>::sse_encode(self.is_default, serializer);
}
}
impl SseEncode for crate::api::BridgeAudioDeviceList {
// Codec=Sse (Serialization based), see doc to use other codecs
fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) {
<Vec<crate::api::BridgeAudioDevice>>::sse_encode(self.input_devices, serializer);
<Vec<crate::api::BridgeAudioDevice>>::sse_encode(self.output_devices, serializer);
}
}
impl SseEncode for crate::api::BridgeAudioProcessingConfig {
// Codec=Sse (Serialization based), see doc to use other codecs
fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) {
@@ -2946,6 +3421,7 @@ impl SseEncode for crate::api::BridgeChannel {
<String>::sse_encode(self.name, serializer);
<i64>::sse_encode(self.order, serializer);
<bool>::sse_encode(self.has_password, serializer);
<Option<i32>>::sse_encode(self.needed_talk_power, serializer);
}
}
@@ -2959,6 +3435,8 @@ impl SseEncode for crate::api::BridgeClient {
<bool>::sse_encode(self.output_muted, serializer);
<bool>::sse_encode(self.is_speaking, serializer);
<bool>::sse_encode(self.is_server_query, serializer);
<i32>::sse_encode(self.talk_power, serializer);
<bool>::sse_encode(self.talk_power_granted, serializer);
}
}
@@ -3105,6 +3583,22 @@ impl SseEncode for crate::api::BridgeEvent {
<String>::sse_encode(permission, serializer);
<crate::api::PermissionStateKind>::sse_encode(state, serializer);
}
crate::api::BridgeEvent::ChatMessage {
sender_id,
sender_name,
message,
target,
} => {
<i32>::sse_encode(11, serializer);
<u64>::sse_encode(sender_id, serializer);
<String>::sse_encode(sender_name, serializer);
<String>::sse_encode(message, serializer);
<crate::api::BridgeMessageTarget>::sse_encode(target, serializer);
}
crate::api::BridgeEvent::AudioRouteChanged { route } => {
<i32>::sse_encode(12, serializer);
<crate::api::BridgeAudioRoute>::sse_encode(route, serializer);
}
_ => {
unimplemented!("");
}
@@ -3128,6 +3622,31 @@ impl SseEncode for crate::api::BridgeIosVoiceProcessingMode {
}
}
impl SseEncode for crate::api::BridgeMessageTarget {
// Codec=Sse (Serialization based), see doc to use other codecs
fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) {
match self {
crate::api::BridgeMessageTarget::Server => {
<i32>::sse_encode(0, serializer);
}
crate::api::BridgeMessageTarget::Channel => {
<i32>::sse_encode(1, serializer);
}
crate::api::BridgeMessageTarget::Client(field0) => {
<i32>::sse_encode(2, serializer);
<u64>::sse_encode(field0, serializer);
}
crate::api::BridgeMessageTarget::Poke(field0) => {
<i32>::sse_encode(3, serializer);
<u64>::sse_encode(field0, serializer);
}
_ => {
unimplemented!("");
}
}
}
}
impl SseEncode for crate::api::BridgeNetworkState {
// Codec=Sse (Serialization based), see doc to use other codecs
fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) {
@@ -3145,6 +3664,23 @@ impl SseEncode for crate::api::BridgeNetworkState {
}
}
impl SseEncode for crate::api::BridgePttBinding {
// Codec=Sse (Serialization based), see doc to use other codecs
fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) {
<String>::sse_encode(self.input_class, serializer);
<String>::sse_encode(self.key_label, serializer);
}
}
impl SseEncode for crate::api::BridgePttDescriptor {
// Codec=Sse (Serialization based), see doc to use other codecs
fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) {
<String>::sse_encode(self.level, serializer);
<String>::sse_encode(self.backend_id, serializer);
<String>::sse_encode(self.bound_input_class, serializer);
}
}
impl SseEncode for crate::api::BridgePttInputClass {
// Codec=Sse (Serialization based), see doc to use other codecs
fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) {
@@ -3274,6 +3810,16 @@ impl SseEncode for i64 {
}
}
impl SseEncode for Vec<crate::api::BridgeAudioDevice> {
// Codec=Sse (Serialization based), see doc to use other codecs
fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) {
<i32>::sse_encode(self.len() as _, serializer);
for item in self {
<crate::api::BridgeAudioDevice>::sse_encode(item, serializer);
}
}
}
impl SseEncode for Vec<crate::api::BridgeBookmark> {
// Codec=Sse (Serialization based), see doc to use other codecs
fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) {
@@ -3314,6 +3860,16 @@ impl SseEncode for Vec<u8> {
}
}
impl SseEncode for Option<String> {
// Codec=Sse (Serialization based), see doc to use other codecs
fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) {
<bool>::sse_encode(self.is_some(), serializer);
if let Some(value) = self {
<String>::sse_encode(value, serializer);
}
}
}
impl SseEncode for Option<crate::api::BridgeVoiceJoinErrorCode> {
// Codec=Sse (Serialization based), see doc to use other codecs
fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) {
@@ -3324,6 +3880,16 @@ impl SseEncode for Option<crate::api::BridgeVoiceJoinErrorCode> {
}
}
impl SseEncode for Option<i32> {
// Codec=Sse (Serialization based), see doc to use other codecs
fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) {
<bool>::sse_encode(self.is_some(), serializer);
if let Some(value) = self {
<i32>::sse_encode(value, serializer);
}
}
}
impl SseEncode for Option<u64> {
// Codec=Sse (Serialization based), see doc to use other codecs
fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) {
@@ -3352,23 +3918,6 @@ impl SseEncode for crate::api::PermissionStateKind {
}
}
impl SseEncode for (String, String) {
// Codec=Sse (Serialization based), see doc to use other codecs
fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) {
<String>::sse_encode(self.0, serializer);
<String>::sse_encode(self.1, serializer);
}
}
impl SseEncode for (String, String, String) {
// Codec=Sse (Serialization based), see doc to use other codecs
fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) {
<String>::sse_encode(self.0, serializer);
<String>::sse_encode(self.1, serializer);
<String>::sse_encode(self.2, serializer);
}
}
impl SseEncode for u32 {
// Codec=Sse (Serialization based), see doc to use other codecs
fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) {
+5 -4
View File
@@ -102,13 +102,14 @@ impl From<chanora_core::CoreError> for BridgeError {
chanora_core::CoreError::AudioNotStarted => {
BridgeError::InvalidCommand("audio not started".to_string())
}
chanora_core::CoreError::Protocol(chanora_protocol::ProtocolError::DnsFailed {
chanora_core::CoreError::Protocol(chanora_core::ProtocolError::DnsFailed {
host,
reason,
}) => BridgeError::DnsFailed { host, reason },
chanora_core::CoreError::Protocol(
chanora_protocol::ProtocolError::ServerRejected { code, message },
) => BridgeError::ServerRejected { code, message },
chanora_core::CoreError::Protocol(chanora_core::ProtocolError::ServerRejected {
code,
message,
}) => BridgeError::ServerRejected { code, message },
chanora_core::CoreError::Protocol(p) => BridgeError::Connection(format!("{p}")),
chanora_core::CoreError::Audio(a) => BridgeError::Connection(format!("audio: {a}")),
chanora_core::CoreError::Storage(s) => BridgeError::Connection(format!("storage: {s}")),
+117
View File
@@ -61,6 +61,14 @@ pub enum DiagnosticsError {
/// to the PoC value so audit grep patterns survive the promotion.
pub const REDACTION_MARKER: &str = "[REDACTED]";
/// SRS-122: In-memory log capacity for release builds.
/// Release builds use a smaller buffer to limit memory footprint and residual data in exports.
#[cfg(not(debug_assertions))]
pub const DEFAULT_LOG_CAPACITY: usize = 256;
/// SRS-122: In-memory log capacity for debug builds.
#[cfg(debug_assertions)]
pub const DEFAULT_LOG_CAPACITY: usize = 4096;
/// Registry of known-secret values that must never appear in logs
/// or exports. Cross-spike contract per SS-AUD-003: the secure
/// storage adapter calls [`Self::register`] every time a secret
@@ -612,6 +620,10 @@ pub struct DiagnosticExport {
/// fragment contains only device-side technical scalars per
/// SDD-090 (no PII, no permission state, no server identity).
pub android_audio: Option<String>,
/// SRS-100: Network connectivity diagnostics summary.
pub network_info: Option<String>,
/// SRS-097: Protocol event recording trace.
pub protocol_events: Vec<String>,
}
impl DiagnosticExport {
@@ -625,6 +637,8 @@ impl DiagnosticExport {
recent_logs: sink.snapshot(),
known_secret_count: sink.redactor().secrets().len(),
android_audio: None,
network_info: None,
protocol_events: Vec::new(),
})
}
@@ -638,6 +652,18 @@ impl DiagnosticExport {
self
}
/// Attach a network connectivity diagnostics fragment (SRS-100).
pub fn with_network_info(mut self, info: Option<String>) -> Self {
self.network_info = info;
self
}
/// Attach protocol event trace (SRS-097).
pub fn with_protocol_events(mut self, events: Vec<String>) -> Self {
self.protocol_events = events;
self
}
/// Render as a plaintext blob suitable for `Share` / `Copy`.
/// The output is multi-line UTF-8, redacted.
pub fn to_text(&self) -> String {
@@ -656,6 +682,17 @@ impl DiagnosticExport {
out.push_str("\n[audio.android]\n");
out.push_str(yaml);
}
if let Some(info) = &self.network_info {
out.push_str("\n[network]\n");
out.push_str(info);
}
if !self.protocol_events.is_empty() {
out.push_str("\n[protocol events]\n");
for ev in &self.protocol_events {
out.push_str(ev);
out.push('\n');
}
}
out.push_str("\n[recent logs]\n");
for line in &self.recent_logs {
out.push_str(line);
@@ -665,6 +702,86 @@ impl DiagnosticExport {
}
}
/// SRS-097/098: Ring-buffer recorder of protocol-level events
/// (connect, disconnect, snapshot changes, channel joins) for
/// diagnostic export and state-sync replay verification.
#[derive(Debug, Clone)]
pub struct ProtocolEventRecorder {
events: Vec<String>,
capacity: usize,
}
impl ProtocolEventRecorder {
/// Create a recorder with the given ring-buffer capacity.
pub fn new(capacity: usize) -> Self {
Self {
events: Vec::with_capacity(capacity),
capacity,
}
}
fn push(&mut self, ts: &str, kind: &str, detail: &str) {
let s = format!("[{ts}] {kind}: {detail}");
if self.events.len() >= self.capacity {
self.events.remove(0);
}
self.events.push(s);
}
/// Record a successful connection.
pub fn record_connected(&mut self, server_name: &str) {
self.push("connect", "connected", server_name);
}
/// Record a graceful or forced disconnect.
pub fn record_disconnected(&mut self, reason: &str) {
self.push("disconnect", "disconnected", reason);
}
/// Record a reconnect attempt.
pub fn record_reconnecting(&mut self, attempt: u64, delay_secs: u64) {
self.push(
"reconnect",
"reconnecting",
&format!("attempt={attempt} delay={delay_secs}s"),
);
}
/// Record a snapshot tree change.
pub fn record_snapshot_changed(&mut self, channels: usize, clients: usize) {
self.push(
"snapshot",
"changed",
&format!("channels={channels} clients={clients}"),
);
}
/// Record a channel join event.
pub fn record_channel_join(&mut self, channel_id: u64, channel_name: &str) {
self.push(
"join",
"channel_joined",
&format!("id={channel_id} name={channel_name}"),
);
}
/// Record a platform lifecycle transition (SRS-138).
pub fn record_lifecycle(&mut self, state: &str) {
self.push("lifecycle", state, "");
}
/// Drain all recorded events and reset the buffer.
pub fn drain(&mut self) -> Vec<String> {
std::mem::take(&mut self.events)
}
}
impl Default for ProtocolEventRecorder {
fn default() -> Self {
Self::new(256)
}
}
#[cfg(test)]
mod tests {
use super::*;
+1
View File
@@ -24,6 +24,7 @@ tsclientlib = { git = "https://github.com/ReSpeak/tsclientlib.git", rev = "04aa2
# avoids any version-skew confusion.
tsproto-packets = { git = "https://github.com/ReSpeak/tsclientlib.git", rev = "04aa2491" }
tsproto-types = { git = "https://github.com/ReSpeak/tsclientlib.git", rev = "04aa2491" }
ts-bookkeeping = { git = "https://github.com/ReSpeak/tsclientlib.git", rev = "04aa2491" }
# Async runtime utilities used by the connection task.
tokio = { version = "1", features = ["macros", "rt-multi-thread", "time", "sync"] }
+162 -5
View File
@@ -33,7 +33,9 @@ use tsclientlib::{
use tsproto_packets::packets::{InAudioBuf, OutPacket};
use tsproto_types::ClientType;
use crate::dto::{ChannelId, ChannelInfo, ClientId, ClientInfo, ServerSnapshot};
use crate::dto::{
ChannelId, ChannelInfo, ChatMessage, ClientId, ClientInfo, MessageTarget, ServerSnapshot,
};
use crate::ProtocolError;
const SPEAKING_ACTIVITY_WINDOW: Duration = Duration::from_millis(750);
@@ -104,8 +106,8 @@ pub struct ConnectConfig {
pub password: Option<String>,
/// Optional pre-existing identity (base64 string accepted by
/// `tsclientlib::Identity::new_from_str`). If `None`, a fresh
/// identity is generated and **not persisted** — production
/// callers must wire this to `chanora_storage::SecretStorageRepository`.
/// identity is generated and **not persisted** — production callers
/// should provide one from secure identity storage.
pub identity: Option<String>,
/// How long to wait for the initial state snapshot before
/// returning `ProtocolError::Timeout`.
@@ -139,6 +141,15 @@ enum Request {
output: Option<bool>,
reply: oneshot::Sender<Result<(), ProtocolError>>,
},
/// Send a text message to a target.
SendTextMessage {
/// Message content.
message: String,
/// Target scope.
target: MessageTarget,
/// Reply channel for outcome.
reply: oneshot::Sender<Result<(), ProtocolError>>,
},
}
/// Why a [`ProtocolClient`] task ended. Distinguishes a user-driven
@@ -170,6 +181,9 @@ pub struct ProtocolClient {
/// auto-reconnect. Wrapped in a Mutex<Option<_>> so it can be
/// taken once by the supervisor and never resurfaced.
lost_rx: std::sync::Mutex<Option<oneshot::Receiver<DisconnectReason>>>,
/// Inbound chat message stream from the connection task. The
/// receiver is taken by the supervisor and forwarded to UI.
chat_rx: std::sync::Mutex<Option<mpsc::Receiver<ChatMessage>>>,
}
/// One inbound voice packet from a remote client.
@@ -228,6 +242,7 @@ impl ProtocolClient {
let (tx, rx) = mpsc::channel::<Request>(8);
let (voice_out_tx, voice_out_rx) = mpsc::channel::<OutPacket>(64);
let (voice_in_tx, voice_in_rx) = mpsc::channel::<InboundVoice>(64);
let (chat_tx, chat_rx) = mpsc::channel::<ChatMessage>(64);
let (ready_tx, ready_rx) = oneshot::channel::<Result<(), ProtocolError>>();
let (lost_tx, lost_rx) = oneshot::channel::<DisconnectReason>();
@@ -236,6 +251,7 @@ impl ProtocolClient {
rx,
voice_out_rx,
voice_in_tx,
chat_tx,
ready_tx,
lost_tx,
));
@@ -246,6 +262,7 @@ impl ProtocolClient {
voice_out_tx,
voice_in_rx: std::sync::Mutex::new(Some(voice_in_rx)),
lost_rx: std::sync::Mutex::new(Some(lost_rx)),
chat_rx: std::sync::Mutex::new(Some(chat_rx)),
}),
Ok(Ok(Err(e))) => Err(e),
Ok(Err(_)) => Err(ProtocolError::Backend(
@@ -360,6 +377,40 @@ impl ProtocolClient {
pub fn take_loss_notifier(&self) -> Option<oneshot::Receiver<DisconnectReason>> {
self.lost_rx.lock().ok().and_then(|mut g| g.take())
}
/// Take the inbound-chat receiver. Returns `None` if it has
/// already been taken; only one consumer is allowed.
pub fn take_chat_rx(&self) -> Option<mpsc::Receiver<ChatMessage>> {
self.chat_rx.lock().ok().and_then(|mut g| g.take())
}
/// Put a previously-taken chat_rx receiver back.
pub fn put_chat_rx(&self, rx: mpsc::Receiver<ChatMessage>) {
if let Ok(mut g) = self.chat_rx.lock() {
if g.is_none() {
*g = Some(rx);
}
}
}
/// Send a text message to the specified target.
pub async fn send_text_message(
&self,
message: String,
target: MessageTarget,
) -> Result<(), ProtocolError> {
let (tx, rx) = oneshot::channel();
self.tx
.send(Request::SendTextMessage {
message,
target,
reply: tx,
})
.await
.map_err(|_| ProtocolError::Lost("connection task is gone".to_string()))?;
rx.await
.map_err(|_| ProtocolError::Lost("send_text_message reply dropped".to_string()))?
}
}
async fn connection_task(
@@ -367,6 +418,7 @@ async fn connection_task(
mut rx: mpsc::Receiver<Request>,
mut voice_out_rx: mpsc::Receiver<OutPacket>,
voice_in_tx: mpsc::Sender<InboundVoice>,
chat_tx: mpsc::Sender<ChatMessage>,
ready_tx: oneshot::Sender<Result<(), ProtocolError>>,
lost_tx: oneshot::Sender<DisconnectReason>,
) {
@@ -545,6 +597,33 @@ async fn connection_task(
}
}
}
StreamItem::BookEvents(events) => {
for ev in events {
if let tsclientlib::events::Event::Message {
target,
invoker,
message,
} = ev
{
let mapped = match target {
tsclientlib::MessageTarget::Server => MessageTarget::Server,
tsclientlib::MessageTarget::Channel => MessageTarget::Channel,
tsclientlib::MessageTarget::Client(id) => {
MessageTarget::Client(id.0 as u64)
}
tsclientlib::MessageTarget::Poke(id) => {
MessageTarget::Poke(id.0 as u64)
}
};
let _ = chat_tx.try_send(ChatMessage {
sender_id: ClientId(invoker.id.0 as u64),
sender_name: sanitize(&invoker.name),
message: sanitize(&message),
target: mapped,
});
}
}
}
StreamItem::MessageResult(handle, result) => {
if let Some((reply, _deadline)) = pending_moves.remove(&handle) {
let mapped = match result {
@@ -644,6 +723,14 @@ async fn connection_task(
let r = set_self_muted(&mut con, input, output);
let _ = reply.send(r);
}
Ok(Request::SendTextMessage {
message,
target,
reply,
}) => {
let r = send_text_message(&mut con, &message, target);
let _ = reply.send(r);
}
Ok(Request::Disconnect(reply)) => {
let _ = con.disconnect(DisconnectOptions::new());
con.events().for_each(|_| future::ready(())).await;
@@ -723,6 +810,74 @@ fn set_self_muted(
Ok(())
}
fn send_text_message(
con: &mut Connection,
message: &str,
target: MessageTarget,
) -> Result<(), ProtocolError> {
use ts_bookkeeping::messages::c2s;
use tsproto_types::TextMessageTargetMode;
match target {
MessageTarget::Server => {
c2s::OutSendTextMessageMessage::new(&mut std::iter::once(
c2s::OutSendTextMessagePart {
target: TextMessageTargetMode::Server,
target_client_id: None,
message: message.into(),
},
))
.send(con)
.map_err(|e| ProtocolError::Backend(format!("send_textmessage(server): {e}")))?;
}
MessageTarget::Channel => {
// Fix: previously channel messages were sent via
// state.server.send_textmessage() which always uses
// TextMessageTargetMode::Server. Now correctly uses
// TextMessageTargetMode::Channel so the message is
// scoped to the current channel, not server-wide.
c2s::OutSendTextMessageMessage::new(&mut std::iter::once(
c2s::OutSendTextMessagePart {
target: TextMessageTargetMode::Channel,
target_client_id: None,
message: message.into(),
},
))
.send(con)
.map_err(|e| ProtocolError::Backend(format!("send_textmessage(channel): {e}")))?;
}
MessageTarget::Client(client_id) => {
let state = con
.get_state()
.map_err(|e| ProtocolError::Backend(format!("get_state: {e}")))?;
let client = state
.clients
.values()
.find(|c| c.id.0 as u64 == client_id)
.ok_or_else(|| ProtocolError::Backend(format!("client {client_id} not found")))?;
client
.send_textmessage(message)
.send(con)
.map_err(|e| ProtocolError::Backend(format!("send_textmessage(client): {e}")))?;
}
MessageTarget::Poke(client_id) => {
let state = con
.get_state()
.map_err(|e| ProtocolError::Backend(format!("get_state: {e}")))?;
let client = state
.clients
.values()
.find(|c| c.id.0 as u64 == client_id)
.ok_or_else(|| ProtocolError::Backend(format!("client {client_id} not found")))?;
client
.poke(message)
.send(con)
.map_err(|e| ProtocolError::Backend(format!("poke: {e}")))?;
}
}
info!(target: "chanora_protocol", len = message.len(), ?target, "text message sent");
Ok(())
}
/// Extract the originating `client_id` from an inbound voice packet.
fn packet_sender_id(buf: &InAudioBuf) -> Option<u64> {
use tsproto_packets::packets::AudioData;
@@ -872,6 +1027,7 @@ fn build_snapshot(
name: sanitize(&c.name),
order: c.order.0 as i64,
has_password: c.has_password.unwrap_or(false),
needed_talk_power: c.needed_talk_power,
})
.collect();
@@ -887,6 +1043,8 @@ fn build_snapshot(
.get(&(c.id.0 as u64))
.is_some_and(|last| last.elapsed() <= SPEAKING_ACTIVITY_WINDOW),
is_server_query: is_server_query_client_type(&c.client_type),
talk_power: c.talk_power,
talk_power_granted: c.talk_power_granted,
})
.collect();
@@ -915,8 +1073,7 @@ fn sanitize(s: &str) -> String {
.collect()
}
#[allow(dead_code)]
const _ROOT_MATCHES_UPSTREAM: () = {
const _: () = {
// Compile-time assertion that ChannelId(0) maps to what tsclientlib
// also considers the root.
let _ = TsChannelId(0);
+33
View File
@@ -25,6 +25,35 @@ pub struct ChannelInfo {
pub order: i64,
/// True when the server marks the channel as password-protected.
pub has_password: bool,
/// Talk power threshold required to speak in this channel.
/// `None` means no talk-power restriction.
pub needed_talk_power: Option<i32>,
}
/// The target scope of a text message (mirrors TS3 `TextMessageTargetMode`).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum MessageTarget {
/// Broadcast to entire server.
Server,
/// Broadcast to current channel.
Channel,
/// Private message to a specific client.
Client(u64),
/// Poke a specific client.
Poke(u64),
}
/// An in-channel text message from a specific client.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ChatMessage {
/// The client id of the sender.
pub sender_id: ClientId,
/// Nickname of the sender, preserved verbatim.
pub sender_name: String,
/// Message content, preserved verbatim.
pub message: String,
/// Target scope of this message.
pub target: MessageTarget,
}
/// One connected client on the server.
@@ -44,6 +73,10 @@ pub struct ClientInfo {
pub is_speaking: bool,
/// True for TeamSpeak ServerQuery clients.
pub is_server_query: bool,
/// Current talk power value assigned by the server.
pub talk_power: i32,
/// True when the server has granted talk power regardless of numeric value.
pub talk_power_granted: bool,
}
/// Snapshot of the server's published state at a moment in time.
+3 -1
View File
@@ -41,7 +41,9 @@ mod dto;
mod resolver;
pub use adapter::{ConnectConfig, DisconnectReason, InboundVoice, ProtocolClient, SnapshotProbe};
pub use dto::{ChannelId, ChannelInfo, ClientId, ClientInfo, ServerSnapshot};
pub use dto::{
ChannelId, ChannelInfo, ChatMessage, ClientId, ClientInfo, MessageTarget, ServerSnapshot,
};
// Re-export the upstream voice types so chanora_audio can build outbound
// voice packets without taking a direct dependency on tsclientlib /
-1
View File
@@ -11,4 +11,3 @@ publish.workspace = true
[dependencies]
thiserror.workspace = true
tracing.workspace = true
+1 -3
View File
@@ -706,9 +706,7 @@ fn pending_key(pending: &JoinPending) -> JoinOutcomeKey {
}
fn key_matches(pending: Option<JoinPending>, key: JoinOutcomeKey) -> bool {
pending
.map(|pending| pending_key(&pending) == key)
.unwrap_or(false)
pending.is_some_and(|pending| pending_key(&pending) == key)
}
fn stale(state: &mut ChannelJoinState, actions: &mut Vec<ChannelJoinAction>) -> JoinReduceStatus {
+186 -107
View File
@@ -1,15 +1,15 @@
//! # `chanora_storage`
//!
//! Two strictly separated repositories per SAD-067:
//! Two strictly separated storage concerns per SAD-067:
//!
//! * [`LocalDatabaseRepository`] — non-secret state (bookmarks,
//! settings, identity *references*) via SQLite. Crate choice:
//! `rusqlite` bundled (DEC-013.1). **Not yet implemented** —
//! `poc/sqlite-storage-spike` lands in v0.4.
//! * [`SecretStorageRepository`] — secret material (identity private
//! keys, server passwords) via platform secure storage. Linux
//! policy: Secret Service preferred, kernel keyutils fallback
//! (DEC-013.2). Other platforms TBD per SS-TC-001/002/004/005.
//! * [`BookmarkRepository`] — non-secret bookmark state via SQLite
//! with optional encrypted password fields. Crate choice:
//! `rusqlite` bundled (DEC-013.1).
//! * [`IdentityFileStore`] — Beta fallback storage for identity
//! material while the platform secure-storage backends mature.
//! Linux policy remains Secret Service preferred, kernel keyutils
//! fallback (DEC-013.2). Other platforms TBD per
//! SS-TC-001/002/004/005.
//!
//! Secret values **never** appear in the local DB (SS-AUD-001/002);
//! bookmarks store only an `identity_ref` lookup name into the
@@ -48,7 +48,7 @@ use std::sync::Mutex;
use chacha20poly1305::aead::{Aead, KeyInit, OsRng};
use chacha20poly1305::{ChaCha20Poly1305, Key, Nonce};
use rand::RngCore;
use rusqlite::{params, Connection};
use rusqlite::{params, Connection, OptionalExtension};
use serde::{Deserialize, Serialize};
use thiserror::Error;
use tracing::{info, warn};
@@ -79,16 +79,6 @@ pub enum StorageError {
Crypto(String),
}
/// Marker trait for the non-secret database side. Concrete impl will
/// land alongside the `BookmarkRepository` / `SettingsRepository`
/// traits promoted from the SQLite PoC.
pub trait LocalDatabaseRepository: Send + Sync {}
/// Marker trait for the platform secure-storage side. Concrete impl
/// will land alongside the `Secret` newtype + per-platform adapters
/// promoted from the secure-storage PoC.
pub trait SecretStorageRepository: Send + Sync {}
/// Audio-related per-identity settings persisted alongside the
/// identity file as a small JSON blob (SDD-095 / SDD-096). These
/// are *not* secrets; they sit beside the encrypted identity in
@@ -118,6 +108,22 @@ struct AudioMeta {
ptt_key_label: String,
}
/// Persisted PTT binding metadata.
///
/// `input_class` is a stable privacy-safe category string, `platform_key`
/// is the opaque identifier consumed by the platform backend, and
/// `key_label` is the display-only label shown in the UI.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PttBindingMeta {
/// Stable input category string (`""`, `"keyboard"`, or
/// `"mouse-side-button"`).
pub input_class: String,
/// Opaque platform key identifier.
pub platform_key: String,
/// Display-only key label.
pub key_label: String,
}
fn default_release_tail_ms() -> u32 {
200
}
@@ -214,59 +220,57 @@ impl IdentityFileStore {
/// Honours `CHANORA_DISABLE_KEYRING=1` for tests and headless
/// environments where a real Secret Service call would block on
/// a missing D-Bus session.
#[allow(unused_variables)]
#[cfg(any(
target_os = "linux",
target_os = "macos",
target_os = "windows",
target_os = "ios"
))]
fn keyring_load(&self) -> Result<Option<[u8; 32]>, StorageError> {
if keyring_disabled() {
return Ok(None);
}
#[cfg(any(
target_os = "linux",
target_os = "macos",
target_os = "windows",
target_os = "ios"
))]
{
use base64::Engine;
let entry = match keyring::Entry::new(Self::KEYRING_SERVICE, &self.keyring_account) {
Ok(e) => e,
Err(e) => {
warn!(target: "chanora_storage", error = %e, "keyring: entry construction failed; falling back to file");
return Ok(None);
}
};
match entry.get_password() {
Ok(b64) => {
let bytes = base64::engine::general_purpose::STANDARD
.decode(b64.as_bytes())
.map_err(|e| StorageError::Crypto(format!("keyring dek decode: {e}")))?;
if bytes.len() != 32 {
return Err(StorageError::Crypto(format!(
"keyring dek length {} (expected 32)",
bytes.len()
)));
}
let mut key = [0u8; 32];
key.copy_from_slice(&bytes);
Ok(Some(key))
}
Err(keyring::Error::NoEntry) => Ok(None),
Err(e) => {
// Bus unreachable, no session, locked keychain
// — best-effort: fall through to file.
warn!(target: "chanora_storage", error = %e, "keyring: lookup failed; falling back to file");
Ok(None)
use base64::Engine;
let entry = match keyring::Entry::new(Self::KEYRING_SERVICE, &self.keyring_account) {
Ok(e) => e,
Err(e) => {
warn!(target: "chanora_storage", error = %e, "keyring: entry construction failed; falling back to file");
return Ok(None);
}
};
match entry.get_password() {
Ok(b64) => {
let bytes = base64::engine::general_purpose::STANDARD
.decode(b64.as_bytes())
.map_err(|e| StorageError::Crypto(format!("keyring dek decode: {e}")))?;
if bytes.len() != 32 {
return Err(StorageError::Crypto(format!(
"keyring dek length {} (expected 32)",
bytes.len()
)));
}
let mut key = [0u8; 32];
key.copy_from_slice(&bytes);
Ok(Some(key))
}
Err(keyring::Error::NoEntry) => Ok(None),
Err(e) => {
// Bus unreachable, no session, locked keychain
// — best-effort: fall through to file.
warn!(target: "chanora_storage", error = %e, "keyring: lookup failed; falling back to file");
Ok(None)
}
}
#[cfg(not(any(
target_os = "linux",
target_os = "macos",
target_os = "windows",
target_os = "ios"
)))]
{
Ok(None)
}
}
#[cfg(not(any(
target_os = "linux",
target_os = "macos",
target_os = "windows",
target_os = "ios"
)))]
fn keyring_load(&self) -> Result<Option<[u8; 32]>, StorageError> {
Ok(None)
}
/// Persist the DEK in the platform keyring. Returns true on
@@ -274,44 +278,42 @@ impl IdentityFileStore {
/// should then fall back to the file path).
///
/// Honours `CHANORA_DISABLE_KEYRING=1`.
#[allow(unused_variables)]
#[cfg(any(
target_os = "linux",
target_os = "macos",
target_os = "windows",
target_os = "ios"
))]
fn keyring_save(&self, key: &[u8; 32]) -> bool {
if keyring_disabled() {
return false;
}
#[cfg(any(
target_os = "linux",
target_os = "macos",
target_os = "windows",
target_os = "ios"
))]
{
use base64::Engine;
let entry = match keyring::Entry::new(Self::KEYRING_SERVICE, &self.keyring_account) {
Ok(e) => e,
Err(_) => return false,
};
let b64 = base64::engine::general_purpose::STANDARD.encode(key);
match entry.set_password(&b64) {
Ok(()) => {
info!(target: "chanora_storage", "DEK stored in platform keyring");
true
}
Err(e) => {
warn!(target: "chanora_storage", error = %e, "keyring: save failed; falling back to file");
false
}
use base64::Engine;
let entry = match keyring::Entry::new(Self::KEYRING_SERVICE, &self.keyring_account) {
Ok(e) => e,
Err(_) => return false,
};
let b64 = base64::engine::general_purpose::STANDARD.encode(key);
match entry.set_password(&b64) {
Ok(()) => {
info!(target: "chanora_storage", "DEK stored in platform keyring");
true
}
Err(e) => {
warn!(target: "chanora_storage", error = %e, "keyring: save failed; falling back to file");
false
}
}
#[cfg(not(any(
target_os = "linux",
target_os = "macos",
target_os = "windows",
target_os = "ios"
)))]
{
false
}
}
#[cfg(not(any(
target_os = "linux",
target_os = "macos",
target_os = "windows",
target_os = "ios"
)))]
fn keyring_save(&self, _key: &[u8; 32]) -> bool {
false
}
fn ensure_dek(&self) -> Result<(), StorageError> {
@@ -562,12 +564,14 @@ impl IdentityFileStore {
self.write_meta(&m)
}
/// Read the persisted PTT binding. Returns
/// `(input_class, platform_key, key_label)` with empty strings
/// meaning "no binding".
pub fn get_ptt_binding(&self) -> (String, String, String) {
/// Read the persisted PTT binding. Empty strings mean "no binding".
pub fn get_ptt_binding(&self) -> PttBindingMeta {
let m = self.read_meta();
(m.ptt_input_class, m.ptt_platform_key, m.ptt_key_label)
PttBindingMeta {
input_class: m.ptt_input_class,
platform_key: m.ptt_platform_key,
key_label: m.ptt_key_label,
}
}
/// Remove any persisted identity. No-op if none exists. Leaves
@@ -874,6 +878,49 @@ impl BookmarkRepository {
Ok(conn.last_insert_rowid())
}
/// Insert or update a bookmark identified by host. If a row
/// with the same host exists, update connection fields while
/// preserving the user-facing display name; otherwise insert.
/// Returns the row id.
pub fn upsert_or_add(&self, b: &Bookmark) -> Result<i64, StorageError> {
let conn = self
.conn
.lock()
.map_err(|_| StorageError::Sqlite("poisoned lock".to_string()))?;
let blob = match (&self.crypto, &b.password) {
(Some(c), Some(pw)) => Some(c.encrypt(pw.as_bytes())?),
_ => None,
};
let plain: Option<&str> = if self.crypto.is_some() {
None
} else {
b.password.as_deref()
};
let existing: Option<i64> = conn
.query_row(
"SELECT id FROM bookmarks WHERE host = ?1",
params![b.host],
|row| row.get(0),
)
.optional()
.map_err(|e| StorageError::Sqlite(format!("select: {e}")))?;
if let Some(id) = existing {
conn.execute(
"UPDATE bookmarks SET nickname = ?1, password = ?2, password_blob = ?3 WHERE id = ?4",
params![b.nickname, plain, blob, id],
)
.map_err(|e| StorageError::Sqlite(format!("update: {e}")))?;
Ok(id)
} else {
conn.execute(
"INSERT INTO bookmarks (display_name, host, nickname, password, password_blob) VALUES (?1, ?2, ?3, ?4, ?5)",
params![b.display_name, b.host, b.nickname, plain, blob],
)
.map_err(|e| StorageError::Sqlite(format!("insert: {e}")))?;
Ok(conn.last_insert_rowid())
}
}
/// Replace an existing bookmark identified by `id`. Errors with
/// [`StorageError::NotFound`] if no such row exists. Honours the
/// password-column encryption setting and clears the legacy
@@ -1067,6 +1114,38 @@ mod tests {
assert!(repo.list().unwrap().is_empty());
}
#[test]
fn bookmark_upsert_preserves_existing_display_name() {
force_keyring_off();
let tmp = tempdir();
let repo = BookmarkRepository::new(&tmp).unwrap();
let id = repo
.add(&Bookmark {
id: 0,
display_name: "my custom title".to_string(),
host: "cn.teamspeak.app".to_string(),
nickname: "old nick".to_string(),
password: None,
})
.unwrap();
let upserted = repo
.upsert_or_add(&Bookmark {
id: 0,
display_name: "live server name".to_string(),
host: "cn.teamspeak.app".to_string(),
nickname: "new nick".to_string(),
password: Some("pw".to_string()),
})
.unwrap();
assert_eq!(upserted, id);
let rows = repo.list().unwrap();
assert_eq!(rows.len(), 1);
assert_eq!(rows[0].display_name, "my custom title");
assert_eq!(rows[0].nickname, "new nick");
assert_eq!(rows[0].password.as_deref(), Some("pw"));
}
#[test]
fn bookmark_update_missing_is_notfound() {
force_keyring_off();
@@ -1228,14 +1307,14 @@ mod tests {
fn tempdir() -> PathBuf {
let p = std::env::temp_dir()
.join("chanora_storage_test")
.join(format!("{}", std::process::id()))
.join(format!(
"{}",
.join(std::process::id().to_string())
.join(
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
.to_string(),
);
fs::create_dir_all(&p).unwrap();
p
}