chore: restore product scaffold to rollback baseline

This commit is contained in:
Edison Jwa
2026-05-29 14:02:04 +09:00
parent 2896f14ec9
commit fe6e07353e
434 changed files with 27278 additions and 63230 deletions
@@ -352,22 +352,7 @@ fun registerCargoNdkBuildTask(profile: String): TaskProvider<*> {
).firstOrNull { File(it).resolve("cargo").exists() }
val rustupHome = System.getenv("RUSTUP_HOME") ?: "$homeDir/.rustup"
val cargoHome = System.getenv("CARGO_HOME") ?: "$homeDir/.cargo"
val hostArch = System.getProperty("os.arch").lowercase()
val defaultRustupToolchain = when {
org.gradle.internal.os.OperatingSystem.current().isMacOsX && hostArch.contains("aarch64") ->
"stable-aarch64-apple-darwin"
org.gradle.internal.os.OperatingSystem.current().isMacOsX ->
"stable-x86_64-apple-darwin"
org.gradle.internal.os.OperatingSystem.current().isWindows && hostArch.contains("aarch64") ->
"stable-aarch64-pc-windows-msvc"
org.gradle.internal.os.OperatingSystem.current().isWindows ->
"stable-x86_64-pc-windows-msvc"
hostArch.contains("aarch64") || hostArch.contains("arm64") ->
"stable-aarch64-unknown-linux-gnu"
else ->
"stable-x86_64-unknown-linux-gnu"
}
val rustupToolchain = System.getenv("RUSTUP_TOOLCHAIN") ?: defaultRustupToolchain
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
@@ -553,7 +538,7 @@ fun registerJniLibsCopyTask(profile: String): TaskProvider<Task> {
// version bump replacing libc++_shared.so).
inputs.file(bridgeSo).withPropertyName("bridgeSo_$abi")
inputs.file(cxxSharedSo).withPropertyName("cxxSharedSo_$abi")
inputs.file(llvmStrip).withPropertyName("llvmStrip")
inputs.file(llvmStrip).withPropertyName("llvmStrip_$abi")
outputs.file(strippedBridgeSo)
outputs.file(File(jniLibsDir, "$abi/libchanora_bridge.so"))
outputs.file(File(jniLibsDir, "$abi/libc++_shared.so"))
@@ -1,6 +1,2 @@
org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
android.useAndroidX=true
# This builtInKotlin flag was added automatically by Flutter migrator
android.builtInKotlin=false
# This newDsl flag was added automatically by Flutter migrator
android.newDsl=false
@@ -34,54 +34,32 @@ import AVFoundation
let session = AVAudioSession.sharedInstance()
try session.setCategory(
.playAndRecord,
mode: .default,
// Mode rationale (re-revisited after the "low playback
// volume" investigation, May 2026):
mode: .voiceChat,
// Mode rationale (May 2026, .voiceChat reinstated):
//
// We've cycled through .voiceChat -> .default -> .voiceChat
// -> .default. Final answer is .default with
// .defaultToSpeaker, driven by these findings:
// We previously used .default mode after discovering that
// .voiceChat routed output through iOS's in-call audio
// channel, which made speaker output barely audible. That
// bug was caused by cpal's RemoteIO unit binding to a stale
// physical transducer after migrating to coreaudio-rs +
// kAudioUnitSubType_VoiceProcessingIO (see
// crates/chanora_audio/src/ios_voice_unit.rs) the route
// binding is correct under either mode because VPIO re-binds
// on overrideOutputAudioPort.
//
// The earlier "speaker selector silent" bug under
// .voiceChat was caused by cpal's RemoteIO unit binding
// to a stale physical transducer. After migrating to
// coreaudio-rs + kAudioUnitSubType_VoiceProcessingIO
// (see crates/chanora_audio/src/ios_voice_unit.rs) the
// route binding is correct under either mode because
// VPIO is the canonical voice unit and re-binds on
// overrideOutputAudioPort. So route switching is no
// longer a deciding factor.
// .voiceChat advantages over .default:
// * Tells iOS this is a VoIP session other apps' audio
// is properly ducked/paused instead of competing.
// * Enables correct Bluetooth HFP negotiation without
// manual workarounds.
// * iOS treats the audio session as a "call" for priority
// purposes (won't be interrupted by notification sounds).
// * System-level CallKit integration (lock-screen controls).
//
// The "broken playback quality" bug under either
// .voiceChat or .default (with VPIO) was actually NOT
// a VPIO problem at all. iOS has TWO independent audio
// channels: the in-call channel (used by .voiceChat /
// .videoChat modes) and the media channel (used by
// .default). The in-call channel:
// * Routes through the phone-call audio path
// * Aggressively ducks non-voice content to the
// earpiece (Apple's "speakerphone vs ear" UX)
// * Volume controlled by separate in-call volume
// hardware, not the side buttons (when not in a
// phone call)
// The media channel:
// * Routes through the standard media playback path
// * No automatic ducking
// * Volume controlled by the side volume buttons
//
// Even with VPIO + .voiceChat producing a perfectly
// good signal, iOS's in-call channel routing made it
// play at "earpiece" loudness on the speaker too \u2014
// user-perceived as "broken and poor" because the
// signal is technically there but barely audible against
// the loud iPhone speaker's noise floor.
//
// Twilio's video-quickstart-ios and Daily.co's patched
// WebRTC both document the same workaround: use .default
// mode with .defaultToSpeaker option even when using
// VPIO for AEC. The VPIO unit itself still does its job
// (echo cancellation, noise suppression, AGC on the mic
// path) \u2014 only the playback routing changes.
// .defaultToSpeaker ensures output goes to the main speaker
// (not the earpiece) by default when no headphones are
// connected, compensating for the in-call channel's tendency
// to route to the earpiece.
//
// References:
// * https://github.com/twilio/video-quickstart-ios/issues/522
@@ -91,9 +69,6 @@ import AVFoundation
// .defaultToSpeaker : route output to the main speaker
// (not the earpiece) by default
// when no headphones are connected.
// This is what makes the audio
// actually audible at normal
// loudness.
// .allowBluetoothHFP : permit Bluetooth Hands-Free
// Profile headsets as both input
// and output.
@@ -245,7 +220,7 @@ import AVFoundation
let session = AVAudioSession.sharedInstance()
try session.setCategory(
.playAndRecord,
mode: .default,
mode: .voiceChat,
options: [.defaultToSpeaker, .allowBluetoothHFP, .allowBluetoothA2DP]
)
try session.setPreferredIOBufferDuration(0.02)
@@ -171,7 +171,7 @@ List<PlatformCapability> _windowsCapabilities() => [
List<PlatformCapability> _linuxCapabilities() => [
const PlatformCapability(
feature: 'Voice capture',
description: 'PipeWire voice I/O with PulseAudio fallback.',
description: 'PulseAudio/ALSA via cpal.',
tier: CapabilityTier.supported,
),
const PlatformCapability(
+58
View File
@@ -22,6 +22,8 @@
"refreshAction": "Refresh",
"diagnosticsAction": "Diagnostics",
"diagnosticsSaveAction": "Save export",
"diagnosticsLiveUpdating": "Live updating",
"shareAction": "Share",
"diagnosticsSaved": "Diagnostic export saved to {path}",
"@diagnosticsSaved": {
"placeholders": { "path": { "type": "String" } }
@@ -39,6 +41,16 @@
"aboutThirdPartyBody": "Chanora is built on tsclientlib, flutter_rust_bridge, platform-native audio backends, rusqlite, and the Flutter framework, among others. See the NOTICE file at the repository root for the current attribution list.",
"copyAction": "Copy",
"closeAction": "Close",
"openAction": "Open",
"cancelAction": "Cancel",
"retryAction": "Retry",
"chatAction": "Chat",
"chatCloseAction": "Close chat",
"chatNewPrivateAction": "New private chat",
"chatSearchClientsHint": "Search clients...",
"chatDirectMessageAction": "Private message",
"chatPokeAction": "Poke",
"clientInfoAction": "Info",
"startAudioAction": "Start audio",
"pttHoldToTalk": "Hold to talk",
"pttTransmitting": "Transmitting…",
@@ -119,6 +131,7 @@
"channelsHeading": "Channels",
"clientsHeading": "Online clients",
"serverWelcomeHeading": "Server welcome message",
"countChannelsAndClients": "{channels} channels • {clients} online",
"@countChannelsAndClients": {
"placeholders": {
@@ -172,6 +185,10 @@
"microphonePermissionBody": "Chanora needs permission to access the microphone. On macOS, go to System Settings → Privacy & Security → Microphone and enable Chanora.",
"microphonePermissionRequiredForVoice": "Microphone permission is required for voice transmission.",
"permissionGrantAction": "Grant",
"startupPermissionsTitle": "Permissions",
"startupPermissionsBody": "Chanora requests microphone, Bluetooth headset, and notification permissions at startup so voice, headset routing, and the foreground session work correctly.",
"startupPermissionsNotNow": "Not now",
"startupPermissionsAllow": "Allow",
"audioRouteSystemDefault": "System default",
"audioRouteEarpiece": "Earpiece",
"audioRouteUsbHeadset": "USB headset",
@@ -181,6 +198,47 @@
"audioRouteChangeFailed": "Could not change audio output.",
"iosAudioInterrupted": "Audio interrupted by system (phone call)",
"iosAudioResuming": "Audio resuming",
"linkTrustTitle": "Open external link?",
"linkTrustBody": "You are about to open a link to:\n\n{domain}",
"@linkTrustBody": {
"placeholders": {
"domain": { "type": "String" }
}
},
"linkTrustRememberDomain": "Trust all links from this domain",
"clientInfoFetchingProfile": "Fetching TeamSpeak profile",
"clientInfoLoadingProfile": "Loading profile...",
"clientInfoProfileUnavailable": "Profile unavailable",
"clientInfoProfileUnavailableBody": "The server did not return profile details for this client.",
"clientInfoIdentitySection": "Identity",
"clientInfoMembershipSection": "Membership",
"clientInfoConnectionSection": "Connection",
"clientInfoHistorySection": "History",
"clientInfoTransferSection": "Transfer",
"clientInfoClientId": "Client ID",
"clientInfoDatabaseId": "Database ID",
"clientInfoUniqueId": "Unique ID",
"clientInfoDescription": "Description",
"clientInfoAvatar": "Avatar",
"clientInfoServerGroups": "Server groups",
"clientInfoChannelGroup": "Channel group",
"clientInfoChannelId": "Channel ID",
"clientInfoOnline": "Online",
"clientInfoIdle": "Idle",
"clientInfoPing": "Ping",
"clientInfoAddress": "Address",
"clientInfoPacketLossClientToServer": "Packet loss C->S",
"clientInfoPacketLossServerToClient": "Packet loss S->C",
"clientInfoFirstConnected": "First connected",
"clientInfoLastConnected": "Last connected",
"clientInfoConnections": "Connections",
"clientInfoDownloadedMonth": "Downloaded this month",
"clientInfoUploadedMonth": "Uploaded this month",
"clientInfoDownloadedTotal": "Downloaded total",
"clientInfoUploadedTotal": "Uploaded total",
"clientInfoUnknown": "Unknown",
"clientInfoHidden": "Hidden",
"clientInfoNone": "None",
"pokeSnackBarClearAction": "Clear",
"pokeSnackBarMoreIndicator": "...",
"pokeSnackBarIncomingNoMessage": "{sender} pokes you",
+53
View File
@@ -21,6 +21,8 @@
"refreshAction": "刷新",
"diagnosticsAction": "诊断信息",
"diagnosticsSaveAction": "保存导出",
"diagnosticsLiveUpdating": "实时更新中",
"shareAction": "分享",
"diagnosticsSaved": "诊断导出已保存到 {path}",
"aboutAction": "关于",
"aboutVersion": "版本 {version}",
@@ -32,6 +34,16 @@
"aboutThirdPartyBody": "Chanora 基于 tsclientlib、flutter_rust_bridge、平台原生音频后端、rusqlite、Flutter 框架等开源组件构建。完整归属信息请参阅仓库根目录的 NOTICE 文件。",
"copyAction": "复制",
"closeAction": "关闭",
"openAction": "打开",
"cancelAction": "取消",
"retryAction": "重试",
"chatAction": "聊天",
"chatCloseAction": "关闭聊天",
"chatNewPrivateAction": "新建私聊",
"chatSearchClientsHint": "搜索用户...",
"chatDirectMessageAction": "私聊",
"chatPokeAction": "戳一下",
"clientInfoAction": "信息",
"startAudioAction": "启动语音",
"pttHoldToTalk": "按住说话",
"pttTransmitting": "正在发送…",
@@ -79,6 +91,7 @@
"channelsHeading": "频道",
"clientsHeading": "在线用户",
"serverWelcomeHeading": "服务器欢迎信息",
"countChannelsAndClients": "{channels} 个频道 • {clients} 在线",
"voiceModePtt": "按键说话",
@@ -126,6 +139,10 @@
"microphonePermissionBody": "Chanora 需要麦克风访问权限。请前往系统设置 → 隐私与安全性 → 麦克风,启用 Chanora。",
"microphonePermissionRequiredForVoice": "语音发送需要麦克风权限。",
"permissionGrantAction": "授权",
"startupPermissionsTitle": "权限",
"startupPermissionsBody": "Chanora 会在启动时请求麦克风、蓝牙耳机和通知权限,以确保语音、耳机路由和前台会话正常工作。",
"startupPermissionsNotNow": "暂不",
"startupPermissionsAllow": "允许",
"audioRouteSystemDefault": "系统默认",
"audioRouteEarpiece": "听筒",
"audioRouteUsbHeadset": "USB 耳机",
@@ -135,6 +152,42 @@
"audioRouteChangeFailed": "无法切换音频输出。",
"iosAudioInterrupted": "系统已中断音频(电话通话)",
"iosAudioResuming": "音频正在恢复",
"linkTrustTitle": "打开外部链接?",
"linkTrustBody": "你将打开指向以下域名的链接:\n\n{domain}",
"linkTrustRememberDomain": "信任来自此域名的所有链接",
"clientInfoFetchingProfile": "正在获取 TeamSpeak 资料",
"clientInfoLoadingProfile": "正在加载资料…",
"clientInfoProfileUnavailable": "资料不可用",
"clientInfoProfileUnavailableBody": "服务器没有返回此用户的资料详情。",
"clientInfoIdentitySection": "身份",
"clientInfoMembershipSection": "成员关系",
"clientInfoConnectionSection": "连接",
"clientInfoHistorySection": "历史",
"clientInfoTransferSection": "传输",
"clientInfoClientId": "用户 ID",
"clientInfoDatabaseId": "数据库 ID",
"clientInfoUniqueId": "唯一 ID",
"clientInfoDescription": "描述",
"clientInfoAvatar": "头像",
"clientInfoServerGroups": "服务器组",
"clientInfoChannelGroup": "频道组",
"clientInfoChannelId": "频道 ID",
"clientInfoOnline": "在线时长",
"clientInfoIdle": "空闲",
"clientInfoPing": "延迟",
"clientInfoAddress": "地址",
"clientInfoPacketLossClientToServer": "丢包 C->S",
"clientInfoPacketLossServerToClient": "丢包 S->C",
"clientInfoFirstConnected": "首次连接",
"clientInfoLastConnected": "上次连接",
"clientInfoConnections": "连接次数",
"clientInfoDownloadedMonth": "本月下载",
"clientInfoUploadedMonth": "本月上传",
"clientInfoDownloadedTotal": "总下载",
"clientInfoUploadedTotal": "总上传",
"clientInfoUnknown": "未知",
"clientInfoHidden": "隐藏",
"clientInfoNone": "无",
"pokeSnackBarClearAction": "清除",
"pokeSnackBarMoreIndicator": "...",
"pokeSnackBarIncomingNoMessage": "{sender} 戳了你一下",
@@ -199,6 +199,18 @@ abstract class AppL10n {
/// **'Save export'**
String get diagnosticsSaveAction;
/// No description provided for @diagnosticsLiveUpdating.
///
/// In en, this message translates to:
/// **'Live updating'**
String get diagnosticsLiveUpdating;
/// No description provided for @shareAction.
///
/// In en, this message translates to:
/// **'Share'**
String get shareAction;
/// No description provided for @diagnosticsSaved.
///
/// In en, this message translates to:
@@ -265,6 +277,66 @@ abstract class AppL10n {
/// **'Close'**
String get closeAction;
/// No description provided for @openAction.
///
/// In en, this message translates to:
/// **'Open'**
String get openAction;
/// No description provided for @cancelAction.
///
/// In en, this message translates to:
/// **'Cancel'**
String get cancelAction;
/// No description provided for @retryAction.
///
/// In en, this message translates to:
/// **'Retry'**
String get retryAction;
/// No description provided for @chatAction.
///
/// In en, this message translates to:
/// **'Chat'**
String get chatAction;
/// No description provided for @chatCloseAction.
///
/// In en, this message translates to:
/// **'Close chat'**
String get chatCloseAction;
/// No description provided for @chatNewPrivateAction.
///
/// In en, this message translates to:
/// **'New private chat'**
String get chatNewPrivateAction;
/// No description provided for @chatSearchClientsHint.
///
/// In en, this message translates to:
/// **'Search clients...'**
String get chatSearchClientsHint;
/// No description provided for @chatDirectMessageAction.
///
/// In en, this message translates to:
/// **'Private message'**
String get chatDirectMessageAction;
/// No description provided for @chatPokeAction.
///
/// In en, this message translates to:
/// **'Poke'**
String get chatPokeAction;
/// No description provided for @clientInfoAction.
///
/// In en, this message translates to:
/// **'Info'**
String get clientInfoAction;
/// No description provided for @startAudioAction.
///
/// In en, this message translates to:
@@ -523,6 +595,12 @@ abstract class AppL10n {
/// **'Online clients'**
String get clientsHeading;
/// No description provided for @serverWelcomeHeading.
///
/// In en, this message translates to:
/// **'Server welcome message'**
String get serverWelcomeHeading;
/// No description provided for @countChannelsAndClients.
///
/// In en, this message translates to:
@@ -769,6 +847,30 @@ abstract class AppL10n {
/// **'Grant'**
String get permissionGrantAction;
/// No description provided for @startupPermissionsTitle.
///
/// In en, this message translates to:
/// **'Permissions'**
String get startupPermissionsTitle;
/// No description provided for @startupPermissionsBody.
///
/// In en, this message translates to:
/// **'Chanora requests microphone, Bluetooth headset, and notification permissions at startup so voice, headset routing, and the foreground session work correctly.'**
String get startupPermissionsBody;
/// No description provided for @startupPermissionsNotNow.
///
/// In en, this message translates to:
/// **'Not now'**
String get startupPermissionsNotNow;
/// No description provided for @startupPermissionsAllow.
///
/// In en, this message translates to:
/// **'Allow'**
String get startupPermissionsAllow;
/// No description provided for @audioRouteSystemDefault.
///
/// In en, this message translates to:
@@ -823,6 +925,222 @@ abstract class AppL10n {
/// **'Audio resuming'**
String get iosAudioResuming;
/// No description provided for @linkTrustTitle.
///
/// In en, this message translates to:
/// **'Open external link?'**
String get linkTrustTitle;
/// No description provided for @linkTrustBody.
///
/// In en, this message translates to:
/// **'You are about to open a link to:\n\n{domain}'**
String linkTrustBody(String domain);
/// No description provided for @linkTrustRememberDomain.
///
/// In en, this message translates to:
/// **'Trust all links from this domain'**
String get linkTrustRememberDomain;
/// No description provided for @clientInfoFetchingProfile.
///
/// In en, this message translates to:
/// **'Fetching TeamSpeak profile'**
String get clientInfoFetchingProfile;
/// No description provided for @clientInfoLoadingProfile.
///
/// In en, this message translates to:
/// **'Loading profile...'**
String get clientInfoLoadingProfile;
/// No description provided for @clientInfoProfileUnavailable.
///
/// In en, this message translates to:
/// **'Profile unavailable'**
String get clientInfoProfileUnavailable;
/// No description provided for @clientInfoProfileUnavailableBody.
///
/// In en, this message translates to:
/// **'The server did not return profile details for this client.'**
String get clientInfoProfileUnavailableBody;
/// No description provided for @clientInfoIdentitySection.
///
/// In en, this message translates to:
/// **'Identity'**
String get clientInfoIdentitySection;
/// No description provided for @clientInfoMembershipSection.
///
/// In en, this message translates to:
/// **'Membership'**
String get clientInfoMembershipSection;
/// No description provided for @clientInfoConnectionSection.
///
/// In en, this message translates to:
/// **'Connection'**
String get clientInfoConnectionSection;
/// No description provided for @clientInfoHistorySection.
///
/// In en, this message translates to:
/// **'History'**
String get clientInfoHistorySection;
/// No description provided for @clientInfoTransferSection.
///
/// In en, this message translates to:
/// **'Transfer'**
String get clientInfoTransferSection;
/// No description provided for @clientInfoClientId.
///
/// In en, this message translates to:
/// **'Client ID'**
String get clientInfoClientId;
/// No description provided for @clientInfoDatabaseId.
///
/// In en, this message translates to:
/// **'Database ID'**
String get clientInfoDatabaseId;
/// No description provided for @clientInfoUniqueId.
///
/// In en, this message translates to:
/// **'Unique ID'**
String get clientInfoUniqueId;
/// No description provided for @clientInfoDescription.
///
/// In en, this message translates to:
/// **'Description'**
String get clientInfoDescription;
/// No description provided for @clientInfoAvatar.
///
/// In en, this message translates to:
/// **'Avatar'**
String get clientInfoAvatar;
/// No description provided for @clientInfoServerGroups.
///
/// In en, this message translates to:
/// **'Server groups'**
String get clientInfoServerGroups;
/// No description provided for @clientInfoChannelGroup.
///
/// In en, this message translates to:
/// **'Channel group'**
String get clientInfoChannelGroup;
/// No description provided for @clientInfoChannelId.
///
/// In en, this message translates to:
/// **'Channel ID'**
String get clientInfoChannelId;
/// No description provided for @clientInfoOnline.
///
/// In en, this message translates to:
/// **'Online'**
String get clientInfoOnline;
/// No description provided for @clientInfoIdle.
///
/// In en, this message translates to:
/// **'Idle'**
String get clientInfoIdle;
/// No description provided for @clientInfoPing.
///
/// In en, this message translates to:
/// **'Ping'**
String get clientInfoPing;
/// No description provided for @clientInfoAddress.
///
/// In en, this message translates to:
/// **'Address'**
String get clientInfoAddress;
/// No description provided for @clientInfoPacketLossClientToServer.
///
/// In en, this message translates to:
/// **'Packet loss C->S'**
String get clientInfoPacketLossClientToServer;
/// No description provided for @clientInfoPacketLossServerToClient.
///
/// In en, this message translates to:
/// **'Packet loss S->C'**
String get clientInfoPacketLossServerToClient;
/// No description provided for @clientInfoFirstConnected.
///
/// In en, this message translates to:
/// **'First connected'**
String get clientInfoFirstConnected;
/// No description provided for @clientInfoLastConnected.
///
/// In en, this message translates to:
/// **'Last connected'**
String get clientInfoLastConnected;
/// No description provided for @clientInfoConnections.
///
/// In en, this message translates to:
/// **'Connections'**
String get clientInfoConnections;
/// No description provided for @clientInfoDownloadedMonth.
///
/// In en, this message translates to:
/// **'Downloaded this month'**
String get clientInfoDownloadedMonth;
/// No description provided for @clientInfoUploadedMonth.
///
/// In en, this message translates to:
/// **'Uploaded this month'**
String get clientInfoUploadedMonth;
/// No description provided for @clientInfoDownloadedTotal.
///
/// In en, this message translates to:
/// **'Downloaded total'**
String get clientInfoDownloadedTotal;
/// No description provided for @clientInfoUploadedTotal.
///
/// In en, this message translates to:
/// **'Uploaded total'**
String get clientInfoUploadedTotal;
/// No description provided for @clientInfoUnknown.
///
/// In en, this message translates to:
/// **'Unknown'**
String get clientInfoUnknown;
/// No description provided for @clientInfoHidden.
///
/// In en, this message translates to:
/// **'Hidden'**
String get clientInfoHidden;
/// No description provided for @clientInfoNone.
///
/// In en, this message translates to:
/// **'None'**
String get clientInfoNone;
/// No description provided for @pokeSnackBarClearAction.
///
/// In en, this message translates to:
@@ -62,6 +62,12 @@ class AppL10nEn extends AppL10n {
@override
String get diagnosticsSaveAction => 'Save export';
@override
String get diagnosticsLiveUpdating => 'Live updating';
@override
String get shareAction => 'Share';
@override
String diagnosticsSaved(String path) {
return 'Diagnostic export saved to $path';
@@ -102,6 +108,36 @@ class AppL10nEn extends AppL10n {
@override
String get closeAction => 'Close';
@override
String get openAction => 'Open';
@override
String get cancelAction => 'Cancel';
@override
String get retryAction => 'Retry';
@override
String get chatAction => 'Chat';
@override
String get chatCloseAction => 'Close chat';
@override
String get chatNewPrivateAction => 'New private chat';
@override
String get chatSearchClientsHint => 'Search clients...';
@override
String get chatDirectMessageAction => 'Private message';
@override
String get chatPokeAction => 'Poke';
@override
String get clientInfoAction => 'Info';
@override
String get startAudioAction => 'Start audio';
@@ -256,6 +292,9 @@ class AppL10nEn extends AppL10n {
@override
String get clientsHeading => 'Online clients';
@override
String get serverWelcomeHeading => 'Server welcome message';
@override
String countChannelsAndClients(int channels, int clients) {
return '$channels channels • $clients online';
@@ -388,6 +427,19 @@ class AppL10nEn extends AppL10n {
@override
String get permissionGrantAction => 'Grant';
@override
String get startupPermissionsTitle => 'Permissions';
@override
String get startupPermissionsBody =>
'Chanora requests microphone, Bluetooth headset, and notification permissions at startup so voice, headset routing, and the foreground session work correctly.';
@override
String get startupPermissionsNotNow => 'Not now';
@override
String get startupPermissionsAllow => 'Allow';
@override
String get audioRouteSystemDefault => 'System default';
@@ -415,6 +467,117 @@ class AppL10nEn extends AppL10n {
@override
String get iosAudioResuming => 'Audio resuming';
@override
String get linkTrustTitle => 'Open external link?';
@override
String linkTrustBody(String domain) {
return 'You are about to open a link to:\n\n$domain';
}
@override
String get linkTrustRememberDomain => 'Trust all links from this domain';
@override
String get clientInfoFetchingProfile => 'Fetching TeamSpeak profile';
@override
String get clientInfoLoadingProfile => 'Loading profile...';
@override
String get clientInfoProfileUnavailable => 'Profile unavailable';
@override
String get clientInfoProfileUnavailableBody =>
'The server did not return profile details for this client.';
@override
String get clientInfoIdentitySection => 'Identity';
@override
String get clientInfoMembershipSection => 'Membership';
@override
String get clientInfoConnectionSection => 'Connection';
@override
String get clientInfoHistorySection => 'History';
@override
String get clientInfoTransferSection => 'Transfer';
@override
String get clientInfoClientId => 'Client ID';
@override
String get clientInfoDatabaseId => 'Database ID';
@override
String get clientInfoUniqueId => 'Unique ID';
@override
String get clientInfoDescription => 'Description';
@override
String get clientInfoAvatar => 'Avatar';
@override
String get clientInfoServerGroups => 'Server groups';
@override
String get clientInfoChannelGroup => 'Channel group';
@override
String get clientInfoChannelId => 'Channel ID';
@override
String get clientInfoOnline => 'Online';
@override
String get clientInfoIdle => 'Idle';
@override
String get clientInfoPing => 'Ping';
@override
String get clientInfoAddress => 'Address';
@override
String get clientInfoPacketLossClientToServer => 'Packet loss C->S';
@override
String get clientInfoPacketLossServerToClient => 'Packet loss S->C';
@override
String get clientInfoFirstConnected => 'First connected';
@override
String get clientInfoLastConnected => 'Last connected';
@override
String get clientInfoConnections => 'Connections';
@override
String get clientInfoDownloadedMonth => 'Downloaded this month';
@override
String get clientInfoUploadedMonth => 'Uploaded this month';
@override
String get clientInfoDownloadedTotal => 'Downloaded total';
@override
String get clientInfoUploadedTotal => 'Uploaded total';
@override
String get clientInfoUnknown => 'Unknown';
@override
String get clientInfoHidden => 'Hidden';
@override
String get clientInfoNone => 'None';
@override
String get pokeSnackBarClearAction => 'Clear';
@@ -59,6 +59,12 @@ class AppL10nZh extends AppL10n {
@override
String get diagnosticsSaveAction => '保存导出';
@override
String get diagnosticsLiveUpdating => '实时更新中';
@override
String get shareAction => '分享';
@override
String diagnosticsSaved(String path) {
return '诊断导出已保存到 $path';
@@ -99,6 +105,36 @@ class AppL10nZh extends AppL10n {
@override
String get closeAction => '关闭';
@override
String get openAction => '打开';
@override
String get cancelAction => '取消';
@override
String get retryAction => '重试';
@override
String get chatAction => '聊天';
@override
String get chatCloseAction => '关闭聊天';
@override
String get chatNewPrivateAction => '新建私聊';
@override
String get chatSearchClientsHint => '搜索用户...';
@override
String get chatDirectMessageAction => '私聊';
@override
String get chatPokeAction => '戳一下';
@override
String get clientInfoAction => '信息';
@override
String get startAudioAction => '启动语音';
@@ -249,6 +285,9 @@ class AppL10nZh extends AppL10n {
@override
String get clientsHeading => '在线用户';
@override
String get serverWelcomeHeading => '服务器欢迎信息';
@override
String countChannelsAndClients(int channels, int clients) {
return '$channels 个频道 • $clients 在线';
@@ -378,6 +417,19 @@ class AppL10nZh extends AppL10n {
@override
String get permissionGrantAction => '授权';
@override
String get startupPermissionsTitle => '权限';
@override
String get startupPermissionsBody =>
'Chanora 会在启动时请求麦克风、蓝牙耳机和通知权限,以确保语音、耳机路由和前台会话正常工作。';
@override
String get startupPermissionsNotNow => '暂不';
@override
String get startupPermissionsAllow => '允许';
@override
String get audioRouteSystemDefault => '系统默认';
@@ -405,6 +457,116 @@ class AppL10nZh extends AppL10n {
@override
String get iosAudioResuming => '音频正在恢复';
@override
String get linkTrustTitle => '打开外部链接?';
@override
String linkTrustBody(String domain) {
return '你将打开指向以下域名的链接:\n\n$domain';
}
@override
String get linkTrustRememberDomain => '信任来自此域名的所有链接';
@override
String get clientInfoFetchingProfile => '正在获取 TeamSpeak 资料';
@override
String get clientInfoLoadingProfile => '正在加载资料…';
@override
String get clientInfoProfileUnavailable => '资料不可用';
@override
String get clientInfoProfileUnavailableBody => '服务器没有返回此用户的资料详情。';
@override
String get clientInfoIdentitySection => '身份';
@override
String get clientInfoMembershipSection => '成员关系';
@override
String get clientInfoConnectionSection => '连接';
@override
String get clientInfoHistorySection => '历史';
@override
String get clientInfoTransferSection => '传输';
@override
String get clientInfoClientId => '用户 ID';
@override
String get clientInfoDatabaseId => '数据库 ID';
@override
String get clientInfoUniqueId => '唯一 ID';
@override
String get clientInfoDescription => '描述';
@override
String get clientInfoAvatar => '头像';
@override
String get clientInfoServerGroups => '服务器组';
@override
String get clientInfoChannelGroup => '频道组';
@override
String get clientInfoChannelId => '频道 ID';
@override
String get clientInfoOnline => '在线时长';
@override
String get clientInfoIdle => '空闲';
@override
String get clientInfoPing => '延迟';
@override
String get clientInfoAddress => '地址';
@override
String get clientInfoPacketLossClientToServer => '丢包 C->S';
@override
String get clientInfoPacketLossServerToClient => '丢包 S->C';
@override
String get clientInfoFirstConnected => '首次连接';
@override
String get clientInfoLastConnected => '上次连接';
@override
String get clientInfoConnections => '连接次数';
@override
String get clientInfoDownloadedMonth => '本月下载';
@override
String get clientInfoUploadedMonth => '本月上传';
@override
String get clientInfoDownloadedTotal => '总下载';
@override
String get clientInfoUploadedTotal => '总上传';
@override
String get clientInfoUnknown => '未知';
@override
String get clientInfoHidden => '隐藏';
@override
String get clientInfoNone => '';
@override
String get pokeSnackBarClearAction => '清除';
File diff suppressed because it is too large Load Diff
@@ -18,19 +18,17 @@ Future<void>? _storageInitFuture;
Future<void>? _vadBootstrapFuture;
StorageDirectoryProvider _storageDirectoryProvider =
getApplicationSupportDirectory;
StorageDirectoryProvider _vadModelDirectoryProvider =
getApplicationSupportDirectory;
StorageInitializer _storageInitializer = _defaultStorageInitializer;
Future<void> _defaultStorageInitializer(String dir) {
return rust.initStorage(dir: dir);
}
Future<File> _copyBundledAssetToManagedDirectory({
Future<File> _copyBundledAssetToDocuments({
required String assetPath,
required String fileName,
}) async {
final dir = await _vadModelDirectoryProvider();
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);
@@ -61,7 +59,7 @@ Future<void> configureBundledVadModels() async {
}
Future<void> _configureBundledVadModelsImpl() async {
final silero = await _copyBundledAssetToManagedDirectory(
final silero = await _copyBundledAssetToDocuments(
assetPath: _sileroVadAsset,
fileName: 'silero_vad.onnx',
);
@@ -126,15 +124,12 @@ Future<void> _wireStorageImpl() async {
@visibleForTesting
void debugResetStorageBootstrap({
StorageDirectoryProvider? storageDirectoryProvider,
StorageDirectoryProvider? vadModelDirectoryProvider,
StorageInitializer? storageInitializer,
}) {
_storageInitFuture = null;
_vadBootstrapFuture = null;
_storageDirectoryProvider =
storageDirectoryProvider ?? getApplicationSupportDirectory;
_vadModelDirectoryProvider =
vadModelDirectoryProvider ?? getApplicationSupportDirectory;
_storageInitializer = storageInitializer ?? _defaultStorageInitializer;
}
@@ -35,6 +35,12 @@ extension ConnectionPhaseState on ConnectionPhase {
bool get canDisconnect => canOpenChat;
bool shouldShowSnapshotLoading({required bool hasSnapshot}) =>
isServerReachable && !hasSnapshot;
bool canOpenChatWithSnapshot({required bool hasSnapshot}) =>
canOpenChat && hasSnapshot;
ConnectionTokens tokens(ColorScheme colorScheme) {
switch (this) {
case ConnectionPhase.idle:
@@ -52,6 +58,15 @@ extension ConnectionPhaseState on ConnectionPhase {
}
}
ConnectionPhase phaseAfterConnectedEvent(
ConnectionPhase phase, {
required bool hasSnapshot,
}) {
return phase == ConnectionPhase.connected && hasSnapshot
? ConnectionPhase.connected
: ConnectionPhase.synchronizing;
}
String connectionStatusText({
required ConnectionPhase phase,
required AppL10n l10n,
@@ -77,3 +92,9 @@ String connectionStatusText({
return l10n.statusIdle;
}
}
ConnectionPhase phaseAfterSnapshotApplied(ConnectionPhase phase) {
return phase == ConnectionPhase.synchronizing
? ConnectionPhase.connected
: phase;
}
@@ -1,4 +1,6 @@
import 'package:flutter/material.dart';
import '../l10n/generated/app_localizations.dart';
import 'package:shared_preferences/shared_preferences.dart';
class LinkTrustService extends ChangeNotifier {
@@ -54,42 +56,45 @@ Future<bool?> showLinkTrustDialog(BuildContext context, String domain) async {
return showDialog<bool>(
context: context,
builder: (ctx) => StatefulBuilder(
builder: (ctx, setDialogState) => AlertDialog(
title: const Text('Open external link?'),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('You are about to open a link to:\n\n$domain'),
const SizedBox(height: 12),
Row(
children: [
SizedBox(
width: 24,
height: 24,
child: Checkbox(
value: remember,
onChanged: (v) =>
setDialogState(() => remember = v ?? false),
builder: (ctx, setDialogState) {
final l10n = AppL10n.of(ctx);
return AlertDialog(
title: Text(l10n.linkTrustTitle),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(l10n.linkTrustBody(domain)),
const SizedBox(height: 12),
Row(
children: [
SizedBox(
width: 24,
height: 24,
child: Checkbox(
value: remember,
onChanged: (v) =>
setDialogState(() => remember = v ?? false),
),
),
),
const SizedBox(width: 8),
const Flexible(child: Text('Trust all links from this domain')),
],
const SizedBox(width: 8),
Flexible(child: Text(l10n.linkTrustRememberDomain)),
],
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.of(ctx).pop(null),
child: Text(l10n.cancelAction),
),
TextButton(
onPressed: () => Navigator.of(ctx).pop(remember),
child: Text(l10n.openAction),
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.of(ctx).pop(null),
child: const Text('Cancel'),
),
TextButton(
onPressed: () => Navigator.of(ctx).pop(remember),
child: const Text('Open'),
),
],
),
);
},
),
);
}
@@ -0,0 +1,31 @@
import 'dart:async';
class PrefetchDebouncer {
PrefetchDebouncer({
required this.onPrefetch,
this.delay = const Duration(milliseconds: 700),
});
final Future<void> Function(String host) onPrefetch;
final Duration delay;
Timer? _timer;
bool _disposed = false;
void schedule(String rawHost) {
if (_disposed) return;
_timer?.cancel();
final host = rawHost.trim();
if (host.isEmpty) return;
_timer = Timer(delay, () {
if (_disposed) return;
unawaited(onPrefetch(host));
});
}
void dispose() {
_disposed = true;
_timer?.cancel();
_timer = null;
}
}
@@ -1,444 +0,0 @@
import 'dart:ffi';
import 'dart:io';
import 'package:flutter/foundation.dart' show visibleForTesting;
import '../src/rust/api.dart' as rust;
class StartupDependencyCheckResult {
const StartupDependencyCheckResult({
required this.issues,
required this.platformLabel,
});
final List<StartupDependencyIssue> issues;
final String platformLabel;
bool get hasIssues => issues.isNotEmpty;
bool get hasBlockingIssues => issues.any((issue) => issue.isRequired);
bool get hasOnnxRuntime => !issues.any((issue) => issue.id == 'linux-onnxruntime');
}
class StartupDependencyIssue {
const StartupDependencyIssue({
required this.id,
required this.title,
required this.summary,
required this.details,
required this.severity,
this.installHints = const [],
});
final String id;
final String title;
final String summary;
final List<String> details;
final StartupDependencySeverity severity;
final List<StartupInstallHint> installHints;
bool get isRequired => severity == StartupDependencySeverity.required;
}
class StartupInstallHint {
const StartupInstallHint({required this.label, required this.command});
final String label;
final String command;
}
enum StartupDependencySeverity { required, recommended }
enum _LinuxDistro { debian, fedora, arch, other }
enum _LinuxArch { x64, arm64, other }
typedef _LibraryProbe = bool Function(String candidate);
typedef _FileExists = Future<bool> Function(String path);
typedef _ResolvedExecutableProvider = String Function();
typedef _CurrentDirectoryProvider = String Function();
typedef _OsReleaseProvider = Future<String?> Function();
typedef _PlatformIsLinux = bool Function();
typedef _LogFilePathProvider = String Function();
typedef _CurrentAbiProvider = Abi Function();
_LibraryProbe _libraryProbe = _defaultLibraryProbe;
_FileExists _fileExists = _defaultFileExists;
_ResolvedExecutableProvider _resolvedExecutableProvider = () =>
Platform.resolvedExecutable;
_CurrentDirectoryProvider _currentDirectoryProvider = () =>
Directory.current.path;
_OsReleaseProvider _osReleaseProvider = _defaultOsReleaseProvider;
_PlatformIsLinux _platformIsLinux = () => Platform.isLinux;
_LogFilePathProvider _logFilePathProvider = rust.logFilePathStr;
_CurrentAbiProvider _currentAbiProvider = Abi.current;
Future<void> logStartupDependencyIssues(
StartupDependencyCheckResult result,
) async {
if (!result.hasIssues) return;
final path = _logFilePathProvider();
if (path.isEmpty) return;
final issues = result.issues
.map(
(issue) =>
'${issue.id}:${issue.isRequired ? 'required' : 'recommended'}:'
'${_sanitizeLogValue(issue.title)}',
)
.join(', ');
final line =
'${DateTime.now().toUtc().toIso8601String()} '
'[startup_dependency_check] '
'platform="${result.platformLabel}" '
'blocking=${result.hasBlockingIssues} '
'issues=[$issues]';
try {
await File(
path,
).writeAsString('$line\n', mode: FileMode.append, flush: true);
} catch (_) {
// Best-effort only: a missing/unwritable log file must not block app startup.
}
}
String _sanitizeLogValue(String value) => value.replaceAll('"', "'");
Future<StartupDependencyCheckResult> checkStartupDependencies() async {
if (!_platformIsLinux()) {
return const StartupDependencyCheckResult(
issues: [],
platformLabel: 'default',
);
}
final distro = await _detectLinuxDistro();
final arch = _detectLinuxArch();
final executableDir = File(_resolvedExecutableProvider()).parent.path;
final issues = <StartupDependencyIssue>[];
if (!_hasAnyLoadableLibrary(const [
'libpipewire-0.3.so.0',
'libpipewire-0.3.so',
])) {
issues.add(_buildPipeWireIssue(distro));
}
if (!_hasAnyLoadableLibrary(const [
'libpulse.so.0',
'libpulse.so',
'libpulse-simple.so.0',
'libpulse-simple.so',
])) {
issues.add(_buildPulseAudioIssue(distro));
}
if (!await _hasOnnxRuntime(executableDir: executableDir)) {
issues.add(_buildOnnxIssue(executableDir: executableDir, arch: arch));
}
return StartupDependencyCheckResult(
issues: issues,
platformLabel: switch (distro) {
_LinuxDistro.debian => 'Debian / Ubuntu',
_LinuxDistro.fedora => 'Fedora',
_LinuxDistro.arch => 'Arch Linux',
_LinuxDistro.other => 'Linux',
},
);
}
bool _defaultLibraryProbe(String candidate) {
try {
DynamicLibrary.open(candidate);
return true;
} catch (_) {
return false;
}
}
Future<bool> _defaultFileExists(String path) => File(path).exists();
Future<String?> _defaultOsReleaseProvider() async {
const path = '/etc/os-release';
final file = File(path);
if (!await file.exists()) {
return null;
}
return file.readAsString();
}
bool _hasAnyLoadableLibrary(List<String> candidates) {
for (final candidate in candidates) {
if (_libraryProbe(candidate)) {
return true;
}
}
return false;
}
Future<bool> _hasOnnxRuntime({required String executableDir}) async {
final envPath = Platform.environment['ORT_DYLIB_PATH'];
if (envPath != null && envPath.isNotEmpty && await _fileExists(envPath)) {
return true;
}
final candidates = <String>{
'$executableDir/lib/libonnxruntime.so',
'$executableDir/libonnxruntime.so',
'${_currentDirectoryProvider()}/libonnxruntime.so',
'/usr/lib/libonnxruntime.so',
'/usr/lib64/libonnxruntime.so',
'/usr/local/lib/libonnxruntime.so',
'/lib/x86_64-linux-gnu/libonnxruntime.so',
'/usr/lib/x86_64-linux-gnu/libonnxruntime.so',
'/lib/aarch64-linux-gnu/libonnxruntime.so',
'/usr/lib/aarch64-linux-gnu/libonnxruntime.so',
};
for (final candidate in candidates) {
if (await _fileExists(candidate)) {
return true;
}
}
for (final dir in const [
'/usr/lib',
'/usr/lib64',
'/usr/local/lib',
'/usr/lib/x86_64-linux-gnu',
'/usr/lib/aarch64-linux-gnu',
]) {
final match = await _firstMatchingDirEntry(dir, 'libonnxruntime.so');
if (match != null) {
return true;
}
}
return _hasAnyLoadableLibrary(const ['libonnxruntime.so']);
}
Future<String?> _firstMatchingDirEntry(String dir, String prefix) async {
final directory = Directory(dir);
if (!await directory.exists()) {
return null;
}
await for (final entity in directory.list(followLinks: false)) {
if (entity is! File) {
continue;
}
final name = entity.uri.pathSegments.isEmpty
? ''
: entity.uri.pathSegments.last;
if (name == prefix || name.startsWith('$prefix.')) {
return entity.path;
}
}
return null;
}
Future<_LinuxDistro> _detectLinuxDistro() async {
final content = await _osReleaseProvider();
if (content == null || content.isEmpty) {
return _LinuxDistro.other;
}
final fields = _parseOsRelease(content);
final id = fields['id'] ?? '';
final idLike = (fields['id_like'] ?? '').split(RegExp(r'\s+'));
final ids = {id, ...idLike};
if (ids.contains('fedora')) {
return _LinuxDistro.fedora;
}
if (ids.contains('ubuntu') || ids.contains('debian')) {
return _LinuxDistro.debian;
}
if (ids.contains('arch')) {
return _LinuxDistro.arch;
}
return _LinuxDistro.other;
}
Map<String, String> _parseOsRelease(String content) {
final fields = <String, String>{};
for (final line in content.split('\n')) {
final separator = line.indexOf('=');
if (separator <= 0) continue;
final key = line.substring(0, separator).trim().toLowerCase();
var value = line.substring(separator + 1).trim().toLowerCase();
if (value.length >= 2 && value.startsWith('"') && value.endsWith('"')) {
value = value.substring(1, value.length - 1);
}
fields[key] = value;
}
return fields;
}
_LinuxArch _detectLinuxArch() {
return switch (_currentAbiProvider()) {
Abi.linuxX64 => _LinuxArch.x64,
Abi.linuxArm64 => _LinuxArch.arm64,
_ => _LinuxArch.other,
};
}
StartupDependencyIssue _buildPipeWireIssue(_LinuxDistro distro) {
final List<StartupInstallHint> hints = switch (distro) {
_LinuxDistro.debian => const <StartupInstallHint>[
StartupInstallHint(
label: 'Debian / Ubuntu',
command: 'sudo apt install libpipewire-0.3-0',
),
],
_LinuxDistro.fedora => const <StartupInstallHint>[
StartupInstallHint(
label: 'Fedora',
command: 'sudo dnf install pipewire-libs',
),
],
_LinuxDistro.arch => const <StartupInstallHint>[
StartupInstallHint(
label: 'Arch Linux',
command: 'sudo pacman -S pipewire',
),
],
_LinuxDistro.other => const <StartupInstallHint>[],
};
return StartupDependencyIssue(
id: 'linux-pipewire-runtime',
title: 'PipeWire runtime is missing',
summary:
'Chanora uses PipeWire as the primary Linux voice backend. Without it, Chanora will try the PulseAudio fallback.',
details: const [
'Install the PipeWire runtime package for your distribution.',
'After installing it, restart Chanora and tap Recheck.',
],
severity: StartupDependencySeverity.recommended,
installHints: hints,
);
}
StartupDependencyIssue _buildPulseAudioIssue(_LinuxDistro distro) {
final List<StartupInstallHint> hints = switch (distro) {
_LinuxDistro.debian => const <StartupInstallHint>[
StartupInstallHint(
label: 'Debian / Ubuntu',
command: 'sudo apt install libpulse0',
),
],
_LinuxDistro.fedora => const <StartupInstallHint>[
StartupInstallHint(
label: 'Fedora',
command: 'sudo dnf install pulseaudio-libs',
),
],
_LinuxDistro.arch => const <StartupInstallHint>[
StartupInstallHint(
label: 'Arch Linux',
command: 'sudo pacman -S libpulse',
),
],
_LinuxDistro.other => const <StartupInstallHint>[],
};
return StartupDependencyIssue(
id: 'linux-pulseaudio-runtime',
title: 'PulseAudio runtime is missing',
summary:
'Chanora uses PulseAudio as the Linux fallback voice backend when PipeWire is unavailable.',
details: const [
'Install the PulseAudio client library package for your distribution.',
'After installing it, restart Chanora and tap Recheck.',
],
severity: StartupDependencySeverity.recommended,
installHints: hints,
);
}
StartupDependencyIssue _buildOnnxIssue({
required String executableDir,
required _LinuxArch arch,
}) {
final bundlePath = '$executableDir/lib/libonnxruntime.so';
final archivePrefix = switch (arch) {
_LinuxArch.x64 => 'onnxruntime-linux-x64-<version>.tgz',
_LinuxArch.arm64 => 'onnxruntime-linux-aarch64-<version>.tgz',
_LinuxArch.other => 'onnxruntime-linux-<arch>-<version>.tgz',
};
final archiveDescription = switch (arch) {
_LinuxArch.x64 =>
'This machine needs the Linux x64 CPU archive ($archivePrefix).',
_LinuxArch.arm64 =>
'This machine needs the Linux ARM64 / aarch64 CPU archive ($archivePrefix).',
_LinuxArch.other =>
'Download the Linux CPU archive that matches this machine ($archivePrefix).',
};
return StartupDependencyIssue(
id: 'linux-onnxruntime',
title: 'ONNX Runtime is not available',
summary:
'Silero voice activity detection needs libonnxruntime.so. Chanora can continue, but it will fall back to simpler voice detection.',
details: [
archiveDescription,
'Open the official ONNX Runtime releases page, download the matching Linux CPU archive, then extract it.',
'Inside the extracted archive, use the file in lib/ named libonnxruntime.so (or the versioned libonnxruntime.so.* file it points to).',
'Use a Chanora build that already bundles ONNX Runtime, or place libonnxruntime.so in the app bundle lib/ directory.',
'You can also point Chanora at an existing ONNX Runtime shared library with ORT_DYLIB_PATH.',
],
severity: StartupDependencySeverity.recommended,
installHints: [
StartupInstallHint(
label: switch (arch) {
_LinuxArch.x64 => 'Download Linux x64 archive',
_LinuxArch.arm64 => 'Download Linux ARM64 archive',
_LinuxArch.other => 'Open release downloads',
},
command: 'https://github.com/microsoft/onnxruntime/releases',
),
StartupInstallHint(
label: 'Archive name to look for',
command: archivePrefix,
),
const StartupInstallHint(
label: 'Official install reference',
command: 'https://onnxruntime.ai/docs/install/',
),
const StartupInstallHint(
label: 'Temporary shell setup',
command: 'export ORT_DYLIB_PATH=/absolute/path/to/libonnxruntime.so',
),
StartupInstallHint(
label: 'Bundle into this app',
command: 'cp /absolute/path/to/libonnxruntime.so $bundlePath',
),
StartupInstallHint(label: 'Bundled file location', command: bundlePath),
],
);
}
@visibleForTesting
void debugResetStartupDependencyCheck({
bool Function(String)? libraryProbe,
Future<bool> Function(String path)? fileExists,
String Function()? resolvedExecutableProvider,
String Function()? currentDirectoryProvider,
Future<String?> Function()? osReleaseProvider,
bool Function()? platformIsLinux,
String Function()? logFilePathProvider,
Abi Function()? currentAbiProvider,
}) {
_libraryProbe = libraryProbe ?? _defaultLibraryProbe;
_fileExists = fileExists ?? _defaultFileExists;
_resolvedExecutableProvider =
resolvedExecutableProvider ?? (() => Platform.resolvedExecutable);
_currentDirectoryProvider =
currentDirectoryProvider ?? (() => Directory.current.path);
_osReleaseProvider = osReleaseProvider ?? _defaultOsReleaseProvider;
_platformIsLinux = platformIsLinux ?? (() => Platform.isLinux);
_logFilePathProvider = logFilePathProvider ?? rust.logFilePathStr;
_currentAbiProvider = currentAbiProvider ?? Abi.current;
}
@@ -1,53 +1,35 @@
import 'dart:convert';
import 'package:shared_preferences/shared_preferences.dart';
enum UiThemeMode {
system,
light,
dark;
static UiThemeMode fromStorage(String? value) {
return UiThemeMode.values.firstWhere(
(mode) => mode.name == value,
orElse: () => UiThemeMode.system,
);
}
}
class UiSettings {
const UiSettings({this.host = '', this.nickname = ''});
const UiSettings({
this.host = '',
this.nickname = '',
this.themeMode = UiThemeMode.system,
});
final String host;
final String nickname;
}
class ClientPlaybackPreference {
const ClientPlaybackPreference({this.volume = 1.0, this.muted = false});
final double volume;
final bool muted;
double get appliedVolume => muted ? 0.0 : volume;
ClientPlaybackPreference copyWith({double? volume, bool? muted}) {
return ClientPlaybackPreference(
volume: _clampVolume(volume ?? this.volume),
muted: muted ?? this.muted,
);
}
Map<String, Object> toJson() => {
'volume': _clampVolume(volume),
'muted': muted,
};
static ClientPlaybackPreference fromJson(Map<String, dynamic> json) {
final volume = json['volume'];
return ClientPlaybackPreference(
volume: _clampVolume(volume is num ? volume.toDouble() : 1.0),
muted: json['muted'] as bool? ?? false,
);
}
static double _clampVolume(double volume) {
if (!volume.isFinite) return 1.0;
return volume.clamp(0.0, 4.0);
}
final UiThemeMode themeMode;
}
class UiPreferencesService {
static const _hostKey = 'ui.host';
static const _nicknameKey = 'ui.nickname';
static const _themeModeKey = 'ui.theme_mode';
static const _permissionsExplainedKey = 'perms_explained';
static const _clientPlaybackPrefsKey = 'audio.client_playback_prefs';
const UiPreferencesService();
@@ -56,6 +38,7 @@ class UiPreferencesService {
return UiSettings(
host: prefs.getString(_hostKey) ?? '',
nickname: prefs.getString(_nicknameKey) ?? '',
themeMode: UiThemeMode.fromStorage(prefs.getString(_themeModeKey)),
);
}
@@ -65,6 +48,11 @@ class UiPreferencesService {
if (nickname != null) await prefs.setString(_nicknameKey, nickname);
}
Future<void> saveThemeMode(UiThemeMode themeMode) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_themeModeKey, themeMode.name);
}
Future<bool> hasExplainedPermissions() async {
final prefs = await SharedPreferences.getInstance();
return prefs.getBool(_permissionsExplainedKey) ?? false;
@@ -74,80 +62,4 @@ class UiPreferencesService {
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(_permissionsExplainedKey, true);
}
Future<Map<String, ClientPlaybackPreference>>
loadClientPlaybackPreferencesForServer(String serverHost) async {
final normalizedHost = _normalizeServerHost(serverHost);
if (normalizedHost.isEmpty) return const {};
final prefs = await SharedPreferences.getInstance();
final raw = prefs.getString(_clientPlaybackPrefsKey);
if (raw == null || raw.isEmpty) return const {};
final decoded = _decodeClientPlaybackPreferences(raw);
final result = <String, ClientPlaybackPreference>{};
for (final entry in decoded.entries) {
final key = entry.key;
final value = entry.value;
final (host, uid) = _splitCompositeKey(key);
if (host != normalizedHost ||
uid.isEmpty ||
value is! Map<String, dynamic>) {
continue;
}
result[uid] = ClientPlaybackPreference.fromJson(value);
}
return result;
}
Future<void> saveClientPlaybackPreference({
required String serverHost,
required String userUid,
double? volume,
bool? muted,
}) async {
final normalizedHost = _normalizeServerHost(serverHost);
final normalizedUid = userUid.trim();
if (normalizedHost.isEmpty || normalizedUid.isEmpty) return;
final prefs = await SharedPreferences.getInstance();
final current = await loadClientPlaybackPreferencesForServer(serverHost);
final previous = current[normalizedUid] ?? const ClientPlaybackPreference();
final updated = previous.copyWith(volume: volume, muted: muted);
final raw = prefs.getString(_clientPlaybackPrefsKey);
final decoded = _decodeClientPlaybackPreferences(raw);
final compositeKey = _compositeKey(normalizedHost, normalizedUid);
if (updated.volume == 1.0 && !updated.muted) {
decoded.remove(compositeKey);
} else {
decoded[compositeKey] = updated.toJson();
}
await prefs.setString(_clientPlaybackPrefsKey, jsonEncode(decoded));
}
String _compositeKey(String normalizedHost, String normalizedUid) {
return '$normalizedHost|$normalizedUid';
}
(String, String) _splitCompositeKey(String key) {
final index = key.indexOf('|');
if (index == -1) return ('', '');
return (key.substring(0, index), key.substring(index + 1));
}
static String _normalizeServerHost(String host) => host.trim().toLowerCase();
Map<String, dynamic> _decodeClientPlaybackPreferences(String? raw) {
if (raw == null || raw.isEmpty) return <String, dynamic>{};
try {
final decoded = jsonDecode(raw);
return decoded is Map<String, dynamic> ? decoded : <String, dynamic>{};
} catch (_) {
return <String, dynamic>{};
}
}
}
+178 -9
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`, `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 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`, `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`, `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`, `from`
// These functions are ignored (category: IgnoreBecauseExplicitAttribute): `from_kotlin_str`, `to_permission_gate`
/// Return the platform-conventional log-file path as a string, or
@@ -33,9 +33,19 @@ Future<BridgeSnapshot> connect({
password: password,
);
/// Warm server address resolution for the active host field. This is
/// intentionally fire-and-forget from the UI perspective: it schedules
/// Rust-side prefetch work and never opens a TS3 session.
Future<void> prefetchServer({required String host}) =>
RustLib.instance.api.crateApiPrefetchServer(host: host);
/// Re-fetch a fresh snapshot from the active connection.
Future<BridgeSnapshot> snapshot() => RustLib.instance.api.crateApiSnapshot();
/// Fetch richer profile and live connection details for one online client.
Future<BridgeClientProfile> clientProfile({required BigInt clientId}) =>
RustLib.instance.api.crateApiClientProfile(clientId: clientId);
/// Disconnect from the server. No-op if not connected.
Future<void> disconnect() => RustLib.instance.api.crateApiDisconnect();
@@ -198,8 +208,7 @@ Future<void> sendChatMessage({
/// On non-Android targets or before a voice session opens the
/// section is omitted. Per SDD-090 every field in that section is
/// a device-side technical scalar — no PII admitted.
Future<String> exportDiagnostics() =>
RustLib.instance.api.crateApiExportDiagnostics();
String exportDiagnostics() => RustLib.instance.api.crateApiExportDiagnostics();
/// Wire the identity persistence store to a platform-private
/// directory. Should be called once on app start after Flutter has
@@ -796,9 +805,6 @@ class BridgeClient {
/// Stable client id.
final BigInt id;
/// Stable TeamSpeak unique identifier.
final String uid;
/// Channel id the client is currently in.
final BigInt channel;
@@ -825,7 +831,6 @@ class BridgeClient {
const BridgeClient({
required this.id,
required this.uid,
required this.channel,
required this.name,
required this.inputMuted,
@@ -839,7 +844,6 @@ class BridgeClient {
@override
int get hashCode =>
id.hashCode ^
uid.hashCode ^
channel.hashCode ^
name.hashCode ^
inputMuted.hashCode ^
@@ -855,7 +859,6 @@ class BridgeClient {
other is BridgeClient &&
runtimeType == other.runtimeType &&
id == other.id &&
uid == other.uid &&
channel == other.channel &&
name == other.name &&
inputMuted == other.inputMuted &&
@@ -866,6 +869,172 @@ class BridgeClient {
talkPowerGranted == other.talkPowerGranted;
}
/// Rich profile and live connection details for one online client.
class BridgeClientProfile {
/// Stable online client id.
final BigInt id;
/// Channel the client is currently in.
final BigInt channel;
/// Nickname.
final String name;
/// TeamSpeak unique id.
final String uniqueId;
/// Stable TeamSpeak database id, when visible.
final BigInt? databaseId;
/// ISO country code, when visible.
final String countryCode;
/// User description, when visible.
final String description;
/// Client version string.
final String version;
/// Client platform string.
final String platform;
/// Account creation time as Unix seconds.
final PlatformInt64? createdUnixSeconds;
/// Last connection time as Unix seconds.
final PlatformInt64? lastConnectedUnixSeconds;
/// Total historical connections.
final BigInt? connectionsTotal;
/// Current online duration in seconds.
final PlatformInt64? onlineSeconds;
/// Current idle time in milliseconds.
final PlatformInt64? idleMilliseconds;
/// Current ping in milliseconds.
final PlatformInt64? pingMilliseconds;
/// Client address. Empty when permission-gated.
final String clientAddress;
/// Resolved server group names.
final List<String> serverGroups;
/// Resolved channel group name.
final String channelGroup;
/// TeamSpeak avatar file path suffix.
final String avatarPath;
/// Downloaded bytes this month.
final BigInt? bytesDownloadedMonth;
/// Uploaded bytes this month.
final BigInt? bytesUploadedMonth;
/// Downloaded bytes across all time.
final BigInt? bytesDownloadedTotal;
/// Uploaded bytes across all time.
final BigInt? bytesUploadedTotal;
/// Client-to-server total packet loss ratio.
final double? packetLossClientToServerTotal;
/// Server-to-client total packet loss ratio.
final double? packetLossServerToClientTotal;
const BridgeClientProfile({
required this.id,
required this.channel,
required this.name,
required this.uniqueId,
this.databaseId,
required this.countryCode,
required this.description,
required this.version,
required this.platform,
this.createdUnixSeconds,
this.lastConnectedUnixSeconds,
this.connectionsTotal,
this.onlineSeconds,
this.idleMilliseconds,
this.pingMilliseconds,
required this.clientAddress,
required this.serverGroups,
required this.channelGroup,
required this.avatarPath,
this.bytesDownloadedMonth,
this.bytesUploadedMonth,
this.bytesDownloadedTotal,
this.bytesUploadedTotal,
this.packetLossClientToServerTotal,
this.packetLossServerToClientTotal,
});
@override
int get hashCode =>
id.hashCode ^
channel.hashCode ^
name.hashCode ^
uniqueId.hashCode ^
databaseId.hashCode ^
countryCode.hashCode ^
description.hashCode ^
version.hashCode ^
platform.hashCode ^
createdUnixSeconds.hashCode ^
lastConnectedUnixSeconds.hashCode ^
connectionsTotal.hashCode ^
onlineSeconds.hashCode ^
idleMilliseconds.hashCode ^
pingMilliseconds.hashCode ^
clientAddress.hashCode ^
serverGroups.hashCode ^
channelGroup.hashCode ^
avatarPath.hashCode ^
bytesDownloadedMonth.hashCode ^
bytesUploadedMonth.hashCode ^
bytesDownloadedTotal.hashCode ^
bytesUploadedTotal.hashCode ^
packetLossClientToServerTotal.hashCode ^
packetLossServerToClientTotal.hashCode;
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is BridgeClientProfile &&
runtimeType == other.runtimeType &&
id == other.id &&
channel == other.channel &&
name == other.name &&
uniqueId == other.uniqueId &&
databaseId == other.databaseId &&
countryCode == other.countryCode &&
description == other.description &&
version == other.version &&
platform == other.platform &&
createdUnixSeconds == other.createdUnixSeconds &&
lastConnectedUnixSeconds == other.lastConnectedUnixSeconds &&
connectionsTotal == other.connectionsTotal &&
onlineSeconds == other.onlineSeconds &&
idleMilliseconds == other.idleMilliseconds &&
pingMilliseconds == other.pingMilliseconds &&
clientAddress == other.clientAddress &&
serverGroups == other.serverGroups &&
channelGroup == other.channelGroup &&
avatarPath == other.avatarPath &&
bytesDownloadedMonth == other.bytesDownloadedMonth &&
bytesUploadedMonth == other.bytesUploadedMonth &&
bytesDownloadedTotal == other.bytesDownloadedTotal &&
bytesUploadedTotal == other.bytesUploadedTotal &&
packetLossClientToServerTotal ==
other.packetLossClientToServerTotal &&
packetLossServerToClientTotal == other.packetLossServerToClientTotal;
}
/// Bridge effect owner for AEC/NS/AGC.
enum BridgeEffectOwner {
/// Platform-owned effect.
@@ -248,7 +248,7 @@ return audioRouteChanged(_that.route);case _:
class BridgeEvent_Connected extends BridgeEvent {
const BridgeEvent_Connected({required this.serverName}): super._();
/// Server name reported by the server snapshot.
final String serverName;
@@ -315,7 +315,7 @@ as String,
class BridgeEvent_Lost extends BridgeEvent {
const BridgeEvent_Lost({required this.reason}): super._();
/// Reason classification from the protocol layer.
final String reason;
@@ -382,7 +382,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;
@@ -452,7 +452,7 @@ as int,
class BridgeEvent_Disconnected extends BridgeEvent {
const BridgeEvent_Disconnected({required this.reason}): super._();
/// Reason classification.
final String reason;
@@ -519,7 +519,7 @@ as String,
class BridgeEvent_AudioStarted extends BridgeEvent {
const BridgeEvent_AudioStarted(): super._();
@@ -551,7 +551,7 @@ String toString() {
class BridgeEvent_AudioStopped extends BridgeEvent {
const BridgeEvent_AudioStopped(): super._();
@@ -583,7 +583,7 @@ String toString() {
class BridgeEvent_SnapshotChanged extends BridgeEvent {
const BridgeEvent_SnapshotChanged({required this.channels, required this.clients}): super._();
/// Latest channel count.
final int channels;
@@ -653,7 +653,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"`,
@@ -729,7 +729,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.
@@ -824,7 +824,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;
@@ -894,7 +894,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;
@@ -964,7 +964,7 @@ as PermissionStateKind,
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;
@@ -1037,7 +1037,7 @@ as BridgeMessageTarget,
@override
@pragma('vm:prefer-inline')
$BridgeMessageTargetCopyWith<$Res> get target {
return $BridgeMessageTargetCopyWith<$Res>(_self.target, (value) {
return _then(_self.copyWith(target: value));
});
@@ -1049,7 +1049,7 @@ $BridgeMessageTargetCopyWith<$Res> get target {
class BridgeEvent_ServerActivity extends BridgeEvent {
const BridgeEvent_ServerActivity({required this.message}): super._();
/// TeamSpeak-style activity line.
final String message;
@@ -1116,7 +1116,7 @@ as String,
class BridgeEvent_AudioRouteChanged extends BridgeEvent {
const BridgeEvent_AudioRouteChanged({required this.route}): super._();
/// The new audio route.
final BridgeAudioRoute route;
@@ -1355,7 +1355,7 @@ return poke(_that.field0);case _:
class BridgeMessageTarget_Server extends BridgeMessageTarget {
const BridgeMessageTarget_Server(): super._();
@@ -1387,7 +1387,7 @@ String toString() {
class BridgeMessageTarget_Channel extends BridgeMessageTarget {
const BridgeMessageTarget_Channel(): super._();
@@ -1419,7 +1419,7 @@ String toString() {
class BridgeMessageTarget_Client extends BridgeMessageTarget {
const BridgeMessageTarget_Client(this.field0): super._();
final BigInt field0;
@@ -1485,7 +1485,7 @@ as BigInt,
class BridgeMessageTarget_Poke extends BridgeMessageTarget {
const BridgeMessageTarget_Poke(this.field0): super._();
final BigInt field0;
@@ -67,7 +67,7 @@ class RustLib extends BaseEntrypoint<RustLibApi, RustLibApiImpl, RustLibWire> {
String get codegenVersion => '2.12.0';
@override
int get rustContentHash => -560177922;
int get rustContentHash => 281698435;
static const kDefaultExternalLibraryLoaderConfig =
ExternalLibraryLoaderConfig(
@@ -87,6 +87,8 @@ abstract class RustLibApi extends BaseApi {
Future<void> crateApiBridgeInit();
Future<BridgeClientProfile> crateApiClientProfile({required BigInt clientId});
Future<BridgeSnapshot> crateApiConnect({
required String host,
required String nickname,
@@ -101,7 +103,7 @@ abstract class RustLibApi extends BaseApi {
Stream<BridgeEvent> crateApiEventsStream();
Future<String> crateApiExportDiagnostics();
String crateApiExportDiagnostics();
Future<BridgeAudioProcessingConfig> crateApiGetAudioProcessingConfig();
@@ -134,6 +136,8 @@ abstract class RustLibApi extends BaseApi {
required String password,
});
Future<void> crateApiPrefetchServer({required String host});
Future<BridgePttDescriptor> crateApiPttDescriptor();
void crateApiRecordLifecycleEvent({required String state});
@@ -314,6 +318,36 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
TaskConstMeta get kCrateApiBridgeInitConstMeta =>
const TaskConstMeta(debugName: "bridge_init", argNames: []);
@override
Future<BridgeClientProfile> crateApiClientProfile({
required BigInt clientId,
}) {
return handler.executeNormal(
NormalTask(
callFfi: (port_) {
final serializer = SseSerializer(generalizedFrbRustBinding);
sse_encode_u_64(clientId, serializer);
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
funcId: 5,
port: port_,
);
},
codec: SseCodec(
decodeSuccessData: sse_decode_bridge_client_profile,
decodeErrorData: sse_decode_bridge_error,
),
constMeta: kCrateApiClientProfileConstMeta,
argValues: [clientId],
apiImpl: this,
),
);
}
TaskConstMeta get kCrateApiClientProfileConstMeta =>
const TaskConstMeta(debugName: "client_profile", argNames: ["clientId"]);
@override
Future<BridgeSnapshot> crateApiConnect({
required String host,
@@ -330,7 +364,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
funcId: 5,
funcId: 6,
port: port_,
);
},
@@ -360,7 +394,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
funcId: 6,
funcId: 7,
port: port_,
);
},
@@ -387,7 +421,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
funcId: 7,
funcId: 8,
port: port_,
);
},
@@ -415,7 +449,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
funcId: 8,
funcId: 9,
port: port_,
);
},
@@ -448,7 +482,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
funcId: 9,
funcId: 10,
port: port_,
);
},
@@ -469,21 +503,16 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
const TaskConstMeta(debugName: "events_stream", argNames: ["sink"]);
@override
Future<String> crateApiExportDiagnostics() {
return handler.executeNormal(
NormalTask(
callFfi: (port_) {
String crateApiExportDiagnostics() {
return handler.executeSync(
SyncTask(
callFfi: () {
final serializer = SseSerializer(generalizedFrbRustBinding);
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
funcId: 10,
port: port_,
);
return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 11)!;
},
codec: SseCodec(
decodeSuccessData: sse_decode_String,
decodeErrorData: sse_decode_bridge_error,
decodeErrorData: null,
),
constMeta: kCrateApiExportDiagnosticsConstMeta,
argValues: [],
@@ -504,7 +533,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
funcId: 11,
funcId: 12,
port: port_,
);
},
@@ -534,7 +563,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
funcId: 12,
funcId: 13,
port: port_,
);
},
@@ -561,7 +590,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
funcId: 13,
funcId: 14,
port: port_,
);
},
@@ -588,7 +617,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
funcId: 14,
funcId: 15,
port: port_,
);
},
@@ -612,7 +641,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
SyncTask(
callFfi: () {
final serializer = SseSerializer(generalizedFrbRustBinding);
return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 15)!;
return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 16)!;
},
codec: SseCodec(
decodeSuccessData: sse_decode_unit,
@@ -635,7 +664,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
callFfi: () {
final serializer = SseSerializer(generalizedFrbRustBinding);
sse_encode_bool(shouldResume, serializer);
return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 16)!;
return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 17)!;
},
codec: SseCodec(
decodeSuccessData: sse_decode_unit,
@@ -661,7 +690,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
callFfi: () {
final serializer = SseSerializer(generalizedFrbRustBinding);
sse_encode_String(routeClass, serializer);
return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 17)!;
return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 18)!;
},
codec: SseCodec(
decodeSuccessData: sse_decode_unit,
@@ -687,7 +716,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
callFfi: () {
final serializer = SseSerializer(generalizedFrbRustBinding);
sse_encode_bridge_audio_route(route, serializer);
return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 18)!;
return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 19)!;
},
codec: SseCodec(
decodeSuccessData: sse_decode_unit,
@@ -715,7 +744,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
funcId: 19,
funcId: 20,
port: port_,
);
},
@@ -742,7 +771,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
funcId: 20,
funcId: 21,
port: port_,
);
},
@@ -769,7 +798,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
funcId: 21,
funcId: 22,
port: port_,
);
},
@@ -796,7 +825,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
funcId: 22,
funcId: 23,
port: port_,
);
},
@@ -820,7 +849,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
SyncTask(
callFfi: () {
final serializer = SseSerializer(generalizedFrbRustBinding);
return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 23)!;
return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 24)!;
},
codec: SseCodec(
decodeSuccessData: sse_decode_String,
@@ -850,7 +879,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
funcId: 24,
funcId: 25,
port: port_,
);
},
@@ -870,6 +899,34 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
argNames: ["channelId", "password"],
);
@override
Future<void> crateApiPrefetchServer({required String host}) {
return handler.executeNormal(
NormalTask(
callFfi: (port_) {
final serializer = SseSerializer(generalizedFrbRustBinding);
sse_encode_String(host, serializer);
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
funcId: 26,
port: port_,
);
},
codec: SseCodec(
decodeSuccessData: sse_decode_unit,
decodeErrorData: sse_decode_bridge_error,
),
constMeta: kCrateApiPrefetchServerConstMeta,
argValues: [host],
apiImpl: this,
),
);
}
TaskConstMeta get kCrateApiPrefetchServerConstMeta =>
const TaskConstMeta(debugName: "prefetch_server", argNames: ["host"]);
@override
Future<BridgePttDescriptor> crateApiPttDescriptor() {
return handler.executeNormal(
@@ -879,7 +936,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
funcId: 25,
funcId: 27,
port: port_,
);
},
@@ -904,7 +961,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
callFfi: () {
final serializer = SseSerializer(generalizedFrbRustBinding);
sse_encode_String(state, serializer);
return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 26)!;
return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 28)!;
},
codec: SseCodec(
decodeSuccessData: sse_decode_unit,
@@ -937,7 +994,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
funcId: 27,
funcId: 29,
port: port_,
);
},
@@ -964,7 +1021,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
callFfi: () {
final serializer = SseSerializer(generalizedFrbRustBinding);
sse_encode_bridge_audio_route(route, serializer);
return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 28)!;
return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 30)!;
},
codec: SseCodec(
decodeSuccessData: sse_decode_unit,
@@ -998,7 +1055,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
funcId: 29,
funcId: 31,
port: port_,
);
},
@@ -1033,7 +1090,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
funcId: 30,
funcId: 32,
port: port_,
);
},
@@ -1063,7 +1120,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
funcId: 31,
funcId: 33,
port: port_,
);
},
@@ -1091,7 +1148,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
funcId: 32,
funcId: 34,
port: port_,
);
},
@@ -1119,7 +1176,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
funcId: 33,
funcId: 35,
port: port_,
);
},
@@ -1149,7 +1206,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
funcId: 34,
funcId: 36,
port: port_,
);
},
@@ -1177,7 +1234,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
callFfi: () {
final serializer = SseSerializer(generalizedFrbRustBinding);
sse_encode_bridge_network_state(state, serializer);
return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 35)!;
return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 37)!;
},
codec: SseCodec(
decodeSuccessData: sse_decode_unit,
@@ -1203,7 +1260,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
funcId: 36,
funcId: 38,
port: port_,
);
},
@@ -1231,7 +1288,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
funcId: 37,
funcId: 39,
port: port_,
);
},
@@ -1259,7 +1316,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
funcId: 38,
funcId: 40,
port: port_,
);
},
@@ -1287,7 +1344,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
funcId: 39,
funcId: 41,
port: port_,
);
},
@@ -1319,7 +1376,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
funcId: 40,
funcId: 42,
port: port_,
);
},
@@ -1349,7 +1406,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
funcId: 41,
funcId: 43,
port: port_,
);
},
@@ -1377,7 +1434,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
funcId: 42,
funcId: 44,
port: port_,
);
},
@@ -1405,7 +1462,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
funcId: 43,
funcId: 45,
port: port_,
);
},
@@ -1432,7 +1489,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
funcId: 44,
funcId: 46,
port: port_,
);
},
@@ -1460,7 +1517,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
funcId: 45,
funcId: 47,
port: port_,
);
},
@@ -1492,7 +1549,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
funcId: 46,
funcId: 48,
port: port_,
);
},
@@ -1521,7 +1578,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
funcId: 47,
funcId: 49,
port: port_,
);
},
@@ -1594,12 +1651,24 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
return dco_decode_bridge_voice_join_error_code(raw);
}
@protected
double dco_decode_box_autoadd_f_32(dynamic raw) {
// Codec=Dco (DartCObject based), see doc to use other codecs
return raw as double;
}
@protected
int dco_decode_box_autoadd_i_32(dynamic raw) {
// Codec=Dco (DartCObject based), see doc to use other codecs
return raw as int;
}
@protected
PlatformInt64 dco_decode_box_autoadd_i_64(dynamic raw) {
// Codec=Dco (DartCObject based), see doc to use other codecs
return dco_decode_i_64(raw);
}
@protected
BigInt dco_decode_box_autoadd_u_64(dynamic raw) {
// Codec=Dco (DartCObject based), see doc to use other codecs
@@ -1756,19 +1825,53 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
BridgeClient dco_decode_bridge_client(dynamic raw) {
// Codec=Dco (DartCObject based), see doc to use other codecs
final arr = raw as List<dynamic>;
if (arr.length != 10)
throw Exception('unexpected arr length: expect 10 but see ${arr.length}');
if (arr.length != 9)
throw Exception('unexpected arr length: expect 9 but see ${arr.length}');
return BridgeClient(
id: dco_decode_u_64(arr[0]),
uid: dco_decode_String(arr[1]),
channel: dco_decode_u_64(arr[2]),
name: dco_decode_String(arr[3]),
inputMuted: dco_decode_bool(arr[4]),
outputMuted: dco_decode_bool(arr[5]),
isSpeaking: dco_decode_bool(arr[6]),
isServerQuery: dco_decode_bool(arr[7]),
talkPower: dco_decode_i_32(arr[8]),
talkPowerGranted: dco_decode_bool(arr[9]),
channel: dco_decode_u_64(arr[1]),
name: dco_decode_String(arr[2]),
inputMuted: dco_decode_bool(arr[3]),
outputMuted: dco_decode_bool(arr[4]),
isSpeaking: dco_decode_bool(arr[5]),
isServerQuery: dco_decode_bool(arr[6]),
talkPower: dco_decode_i_32(arr[7]),
talkPowerGranted: dco_decode_bool(arr[8]),
);
}
@protected
BridgeClientProfile dco_decode_bridge_client_profile(dynamic raw) {
// Codec=Dco (DartCObject based), see doc to use other codecs
final arr = raw as List<dynamic>;
if (arr.length != 25)
throw Exception('unexpected arr length: expect 25 but see ${arr.length}');
return BridgeClientProfile(
id: dco_decode_u_64(arr[0]),
channel: dco_decode_u_64(arr[1]),
name: dco_decode_String(arr[2]),
uniqueId: dco_decode_String(arr[3]),
databaseId: dco_decode_opt_box_autoadd_u_64(arr[4]),
countryCode: dco_decode_String(arr[5]),
description: dco_decode_String(arr[6]),
version: dco_decode_String(arr[7]),
platform: dco_decode_String(arr[8]),
createdUnixSeconds: dco_decode_opt_box_autoadd_i_64(arr[9]),
lastConnectedUnixSeconds: dco_decode_opt_box_autoadd_i_64(arr[10]),
connectionsTotal: dco_decode_opt_box_autoadd_u_64(arr[11]),
onlineSeconds: dco_decode_opt_box_autoadd_i_64(arr[12]),
idleMilliseconds: dco_decode_opt_box_autoadd_i_64(arr[13]),
pingMilliseconds: dco_decode_opt_box_autoadd_i_64(arr[14]),
clientAddress: dco_decode_String(arr[15]),
serverGroups: dco_decode_list_String(arr[16]),
channelGroup: dco_decode_String(arr[17]),
avatarPath: dco_decode_String(arr[18]),
bytesDownloadedMonth: dco_decode_opt_box_autoadd_u_64(arr[19]),
bytesUploadedMonth: dco_decode_opt_box_autoadd_u_64(arr[20]),
bytesDownloadedTotal: dco_decode_opt_box_autoadd_u_64(arr[21]),
bytesUploadedTotal: dco_decode_opt_box_autoadd_u_64(arr[22]),
packetLossClientToServerTotal: dco_decode_opt_box_autoadd_f_32(arr[23]),
packetLossServerToClientTotal: dco_decode_opt_box_autoadd_f_32(arr[24]),
);
}
@@ -2004,6 +2107,12 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
return dcoDecodeI64(raw);
}
@protected
List<String> dco_decode_list_String(dynamic raw) {
// Codec=Dco (DartCObject based), see doc to use other codecs
return (raw as List<dynamic>).map(dco_decode_String).toList();
}
@protected
List<BridgeAudioDevice> dco_decode_list_bridge_audio_device(dynamic raw) {
// Codec=Dco (DartCObject based), see doc to use other codecs
@@ -2049,12 +2158,24 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
: dco_decode_box_autoadd_bridge_voice_join_error_code(raw);
}
@protected
double? dco_decode_opt_box_autoadd_f_32(dynamic raw) {
// Codec=Dco (DartCObject based), see doc to use other codecs
return raw == null ? null : dco_decode_box_autoadd_f_32(raw);
}
@protected
int? dco_decode_opt_box_autoadd_i_32(dynamic raw) {
// Codec=Dco (DartCObject based), see doc to use other codecs
return raw == null ? null : dco_decode_box_autoadd_i_32(raw);
}
@protected
PlatformInt64? dco_decode_opt_box_autoadd_i_64(dynamic raw) {
// Codec=Dco (DartCObject based), see doc to use other codecs
return raw == null ? null : dco_decode_box_autoadd_i_64(raw);
}
@protected
BigInt? dco_decode_opt_box_autoadd_u_64(dynamic raw) {
// Codec=Dco (DartCObject based), see doc to use other codecs
@@ -2152,12 +2273,24 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
return (sse_decode_bridge_voice_join_error_code(deserializer));
}
@protected
double sse_decode_box_autoadd_f_32(SseDeserializer deserializer) {
// Codec=Sse (Serialization based), see doc to use other codecs
return (sse_decode_f_32(deserializer));
}
@protected
int sse_decode_box_autoadd_i_32(SseDeserializer deserializer) {
// Codec=Sse (Serialization based), see doc to use other codecs
return (sse_decode_i_32(deserializer));
}
@protected
PlatformInt64 sse_decode_box_autoadd_i_64(SseDeserializer deserializer) {
// Codec=Sse (Serialization based), see doc to use other codecs
return (sse_decode_i_64(deserializer));
}
@protected
BigInt sse_decode_box_autoadd_u_64(SseDeserializer deserializer) {
// Codec=Sse (Serialization based), see doc to use other codecs
@@ -2358,7 +2491,6 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
BridgeClient sse_decode_bridge_client(SseDeserializer deserializer) {
// Codec=Sse (Serialization based), see doc to use other codecs
var var_id = sse_decode_u_64(deserializer);
var var_uid = sse_decode_String(deserializer);
var var_channel = sse_decode_u_64(deserializer);
var var_name = sse_decode_String(deserializer);
var var_inputMuted = sse_decode_bool(deserializer);
@@ -2369,7 +2501,6 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
var var_talkPowerGranted = sse_decode_bool(deserializer);
return BridgeClient(
id: var_id,
uid: var_uid,
channel: var_channel,
name: var_name,
inputMuted: var_inputMuted,
@@ -2381,6 +2512,75 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
);
}
@protected
BridgeClientProfile sse_decode_bridge_client_profile(
SseDeserializer deserializer,
) {
// Codec=Sse (Serialization based), see doc to use other codecs
var var_id = sse_decode_u_64(deserializer);
var var_channel = sse_decode_u_64(deserializer);
var var_name = sse_decode_String(deserializer);
var var_uniqueId = sse_decode_String(deserializer);
var var_databaseId = sse_decode_opt_box_autoadd_u_64(deserializer);
var var_countryCode = sse_decode_String(deserializer);
var var_description = sse_decode_String(deserializer);
var var_version = sse_decode_String(deserializer);
var var_platform = sse_decode_String(deserializer);
var var_createdUnixSeconds = sse_decode_opt_box_autoadd_i_64(deserializer);
var var_lastConnectedUnixSeconds = sse_decode_opt_box_autoadd_i_64(
deserializer,
);
var var_connectionsTotal = sse_decode_opt_box_autoadd_u_64(deserializer);
var var_onlineSeconds = sse_decode_opt_box_autoadd_i_64(deserializer);
var var_idleMilliseconds = sse_decode_opt_box_autoadd_i_64(deserializer);
var var_pingMilliseconds = sse_decode_opt_box_autoadd_i_64(deserializer);
var var_clientAddress = sse_decode_String(deserializer);
var var_serverGroups = sse_decode_list_String(deserializer);
var var_channelGroup = sse_decode_String(deserializer);
var var_avatarPath = sse_decode_String(deserializer);
var var_bytesDownloadedMonth = sse_decode_opt_box_autoadd_u_64(
deserializer,
);
var var_bytesUploadedMonth = sse_decode_opt_box_autoadd_u_64(deserializer);
var var_bytesDownloadedTotal = sse_decode_opt_box_autoadd_u_64(
deserializer,
);
var var_bytesUploadedTotal = sse_decode_opt_box_autoadd_u_64(deserializer);
var var_packetLossClientToServerTotal = sse_decode_opt_box_autoadd_f_32(
deserializer,
);
var var_packetLossServerToClientTotal = sse_decode_opt_box_autoadd_f_32(
deserializer,
);
return BridgeClientProfile(
id: var_id,
channel: var_channel,
name: var_name,
uniqueId: var_uniqueId,
databaseId: var_databaseId,
countryCode: var_countryCode,
description: var_description,
version: var_version,
platform: var_platform,
createdUnixSeconds: var_createdUnixSeconds,
lastConnectedUnixSeconds: var_lastConnectedUnixSeconds,
connectionsTotal: var_connectionsTotal,
onlineSeconds: var_onlineSeconds,
idleMilliseconds: var_idleMilliseconds,
pingMilliseconds: var_pingMilliseconds,
clientAddress: var_clientAddress,
serverGroups: var_serverGroups,
channelGroup: var_channelGroup,
avatarPath: var_avatarPath,
bytesDownloadedMonth: var_bytesDownloadedMonth,
bytesUploadedMonth: var_bytesUploadedMonth,
bytesDownloadedTotal: var_bytesDownloadedTotal,
bytesUploadedTotal: var_bytesUploadedTotal,
packetLossClientToServerTotal: var_packetLossClientToServerTotal,
packetLossServerToClientTotal: var_packetLossServerToClientTotal,
);
}
@protected
BridgeEffectOwner sse_decode_bridge_effect_owner(
SseDeserializer deserializer,
@@ -2680,6 +2880,18 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
return deserializer.buffer.getPlatformInt64();
}
@protected
List<String> sse_decode_list_String(SseDeserializer deserializer) {
// Codec=Sse (Serialization based), see doc to use other codecs
var len_ = sse_decode_i_32(deserializer);
var ans_ = <String>[];
for (var idx_ = 0; idx_ < len_; ++idx_) {
ans_.add(sse_decode_String(deserializer));
}
return ans_;
}
@protected
List<BridgeAudioDevice> sse_decode_list_bridge_audio_device(
SseDeserializer deserializer,
@@ -2770,6 +2982,17 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
}
}
@protected
double? sse_decode_opt_box_autoadd_f_32(SseDeserializer deserializer) {
// Codec=Sse (Serialization based), see doc to use other codecs
if (sse_decode_bool(deserializer)) {
return (sse_decode_box_autoadd_f_32(deserializer));
} else {
return null;
}
}
@protected
int? sse_decode_opt_box_autoadd_i_32(SseDeserializer deserializer) {
// Codec=Sse (Serialization based), see doc to use other codecs
@@ -2781,6 +3004,17 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
}
}
@protected
PlatformInt64? sse_decode_opt_box_autoadd_i_64(SseDeserializer deserializer) {
// Codec=Sse (Serialization based), see doc to use other codecs
if (sse_decode_bool(deserializer)) {
return (sse_decode_box_autoadd_i_64(deserializer));
} else {
return null;
}
}
@protected
BigInt? sse_decode_opt_box_autoadd_u_64(SseDeserializer deserializer) {
// Codec=Sse (Serialization based), see doc to use other codecs
@@ -2898,12 +3132,27 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
sse_encode_bridge_voice_join_error_code(self, serializer);
}
@protected
void sse_encode_box_autoadd_f_32(double self, SseSerializer serializer) {
// Codec=Sse (Serialization based), see doc to use other codecs
sse_encode_f_32(self, serializer);
}
@protected
void sse_encode_box_autoadd_i_32(int self, SseSerializer serializer) {
// Codec=Sse (Serialization based), see doc to use other codecs
sse_encode_i_32(self, serializer);
}
@protected
void sse_encode_box_autoadd_i_64(
PlatformInt64 self,
SseSerializer serializer,
) {
// Codec=Sse (Serialization based), see doc to use other codecs
sse_encode_i_64(self, serializer);
}
@protected
void sse_encode_box_autoadd_u_64(BigInt self, SseSerializer serializer) {
// Codec=Sse (Serialization based), see doc to use other codecs
@@ -3056,6 +3305,45 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
sse_encode_bool(self.talkPowerGranted, serializer);
}
@protected
void sse_encode_bridge_client_profile(
BridgeClientProfile self,
SseSerializer serializer,
) {
// Codec=Sse (Serialization based), see doc to use other codecs
sse_encode_u_64(self.id, serializer);
sse_encode_u_64(self.channel, serializer);
sse_encode_String(self.name, serializer);
sse_encode_String(self.uniqueId, serializer);
sse_encode_opt_box_autoadd_u_64(self.databaseId, serializer);
sse_encode_String(self.countryCode, serializer);
sse_encode_String(self.description, serializer);
sse_encode_String(self.version, serializer);
sse_encode_String(self.platform, serializer);
sse_encode_opt_box_autoadd_i_64(self.createdUnixSeconds, serializer);
sse_encode_opt_box_autoadd_i_64(self.lastConnectedUnixSeconds, serializer);
sse_encode_opt_box_autoadd_u_64(self.connectionsTotal, serializer);
sse_encode_opt_box_autoadd_i_64(self.onlineSeconds, serializer);
sse_encode_opt_box_autoadd_i_64(self.idleMilliseconds, serializer);
sse_encode_opt_box_autoadd_i_64(self.pingMilliseconds, serializer);
sse_encode_String(self.clientAddress, serializer);
sse_encode_list_String(self.serverGroups, serializer);
sse_encode_String(self.channelGroup, serializer);
sse_encode_String(self.avatarPath, serializer);
sse_encode_opt_box_autoadd_u_64(self.bytesDownloadedMonth, serializer);
sse_encode_opt_box_autoadd_u_64(self.bytesUploadedMonth, serializer);
sse_encode_opt_box_autoadd_u_64(self.bytesDownloadedTotal, serializer);
sse_encode_opt_box_autoadd_u_64(self.bytesUploadedTotal, serializer);
sse_encode_opt_box_autoadd_f_32(
self.packetLossClientToServerTotal,
serializer,
);
sse_encode_opt_box_autoadd_f_32(
self.packetLossServerToClientTotal,
serializer,
);
}
@protected
void sse_encode_bridge_effect_owner(
BridgeEffectOwner self,
@@ -3330,6 +3618,15 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
serializer.buffer.putPlatformInt64(self);
}
@protected
void sse_encode_list_String(List<String> self, SseSerializer serializer) {
// Codec=Sse (Serialization based), see doc to use other codecs
sse_encode_i_32(self.length, serializer);
for (final item in self) {
sse_encode_String(item, serializer);
}
}
@protected
void sse_encode_list_bridge_audio_device(
List<BridgeAudioDevice> self,
@@ -3411,6 +3708,16 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
}
}
@protected
void sse_encode_opt_box_autoadd_f_32(double? self, SseSerializer serializer) {
// Codec=Sse (Serialization based), see doc to use other codecs
sse_encode_bool(self != null, serializer);
if (self != null) {
sse_encode_box_autoadd_f_32(self, serializer);
}
}
@protected
void sse_encode_opt_box_autoadd_i_32(int? self, SseSerializer serializer) {
// Codec=Sse (Serialization based), see doc to use other codecs
@@ -3421,6 +3728,19 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
}
}
@protected
void sse_encode_opt_box_autoadd_i_64(
PlatformInt64? self,
SseSerializer serializer,
) {
// Codec=Sse (Serialization based), see doc to use other codecs
sse_encode_bool(self != null, serializer);
if (self != null) {
sse_encode_box_autoadd_i_64(self, serializer);
}
}
@protected
void sse_encode_opt_box_autoadd_u_64(BigInt? self, SseSerializer serializer) {
// Codec=Sse (Serialization based), see doc to use other codecs
@@ -48,9 +48,15 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
dynamic raw,
);
@protected
double dco_decode_box_autoadd_f_32(dynamic raw);
@protected
int dco_decode_box_autoadd_i_32(dynamic raw);
@protected
PlatformInt64 dco_decode_box_autoadd_i_64(dynamic raw);
@protected
BigInt dco_decode_box_autoadd_u_64(dynamic raw);
@@ -88,6 +94,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
@protected
BridgeClient dco_decode_bridge_client(dynamic raw);
@protected
BridgeClientProfile dco_decode_bridge_client_profile(dynamic raw);
@protected
BridgeEffectOwner dco_decode_bridge_effect_owner(dynamic raw);
@@ -141,6 +150,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
@protected
PlatformInt64 dco_decode_i_64(dynamic raw);
@protected
List<String> dco_decode_list_String(dynamic raw);
@protected
List<BridgeAudioDevice> dco_decode_list_bridge_audio_device(dynamic raw);
@@ -163,9 +175,15 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
BridgeVoiceJoinErrorCode?
dco_decode_opt_box_autoadd_bridge_voice_join_error_code(dynamic raw);
@protected
double? dco_decode_opt_box_autoadd_f_32(dynamic raw);
@protected
int? dco_decode_opt_box_autoadd_i_32(dynamic raw);
@protected
PlatformInt64? dco_decode_opt_box_autoadd_i_64(dynamic raw);
@protected
BigInt? dco_decode_opt_box_autoadd_u_64(dynamic raw);
@@ -219,9 +237,15 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
SseDeserializer deserializer,
);
@protected
double sse_decode_box_autoadd_f_32(SseDeserializer deserializer);
@protected
int sse_decode_box_autoadd_i_32(SseDeserializer deserializer);
@protected
PlatformInt64 sse_decode_box_autoadd_i_64(SseDeserializer deserializer);
@protected
BigInt sse_decode_box_autoadd_u_64(SseDeserializer deserializer);
@@ -265,6 +289,11 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
@protected
BridgeClient sse_decode_bridge_client(SseDeserializer deserializer);
@protected
BridgeClientProfile sse_decode_bridge_client_profile(
SseDeserializer deserializer,
);
@protected
BridgeEffectOwner sse_decode_bridge_effect_owner(
SseDeserializer deserializer,
@@ -334,6 +363,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
@protected
PlatformInt64 sse_decode_i_64(SseDeserializer deserializer);
@protected
List<String> sse_decode_list_String(SseDeserializer deserializer);
@protected
List<BridgeAudioDevice> sse_decode_list_bridge_audio_device(
SseDeserializer deserializer,
@@ -366,9 +398,15 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
SseDeserializer deserializer,
);
@protected
double? sse_decode_opt_box_autoadd_f_32(SseDeserializer deserializer);
@protected
int? sse_decode_opt_box_autoadd_i_32(SseDeserializer deserializer);
@protected
PlatformInt64? sse_decode_opt_box_autoadd_i_64(SseDeserializer deserializer);
@protected
BigInt? sse_decode_opt_box_autoadd_u_64(SseDeserializer deserializer);
@@ -431,9 +469,18 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
SseSerializer serializer,
);
@protected
void sse_encode_box_autoadd_f_32(double self, SseSerializer serializer);
@protected
void sse_encode_box_autoadd_i_32(int self, SseSerializer serializer);
@protected
void sse_encode_box_autoadd_i_64(
PlatformInt64 self,
SseSerializer serializer,
);
@protected
void sse_encode_box_autoadd_u_64(BigInt self, SseSerializer serializer);
@@ -491,6 +538,12 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
@protected
void sse_encode_bridge_client(BridgeClient self, SseSerializer serializer);
@protected
void sse_encode_bridge_client_profile(
BridgeClientProfile self,
SseSerializer serializer,
);
@protected
void sse_encode_bridge_effect_owner(
BridgeEffectOwner self,
@@ -578,6 +631,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
@protected
void sse_encode_i_64(PlatformInt64 self, SseSerializer serializer);
@protected
void sse_encode_list_String(List<String> self, SseSerializer serializer);
@protected
void sse_encode_list_bridge_audio_device(
List<BridgeAudioDevice> self,
@@ -617,9 +673,18 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
SseSerializer serializer,
);
@protected
void sse_encode_opt_box_autoadd_f_32(double? self, SseSerializer serializer);
@protected
void sse_encode_opt_box_autoadd_i_32(int? self, SseSerializer serializer);
@protected
void sse_encode_opt_box_autoadd_i_64(
PlatformInt64? self,
SseSerializer serializer,
);
@protected
void sse_encode_opt_box_autoadd_u_64(BigInt? self, SseSerializer serializer);
@@ -50,9 +50,15 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
dynamic raw,
);
@protected
double dco_decode_box_autoadd_f_32(dynamic raw);
@protected
int dco_decode_box_autoadd_i_32(dynamic raw);
@protected
PlatformInt64 dco_decode_box_autoadd_i_64(dynamic raw);
@protected
BigInt dco_decode_box_autoadd_u_64(dynamic raw);
@@ -90,6 +96,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
@protected
BridgeClient dco_decode_bridge_client(dynamic raw);
@protected
BridgeClientProfile dco_decode_bridge_client_profile(dynamic raw);
@protected
BridgeEffectOwner dco_decode_bridge_effect_owner(dynamic raw);
@@ -143,6 +152,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
@protected
PlatformInt64 dco_decode_i_64(dynamic raw);
@protected
List<String> dco_decode_list_String(dynamic raw);
@protected
List<BridgeAudioDevice> dco_decode_list_bridge_audio_device(dynamic raw);
@@ -165,9 +177,15 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
BridgeVoiceJoinErrorCode?
dco_decode_opt_box_autoadd_bridge_voice_join_error_code(dynamic raw);
@protected
double? dco_decode_opt_box_autoadd_f_32(dynamic raw);
@protected
int? dco_decode_opt_box_autoadd_i_32(dynamic raw);
@protected
PlatformInt64? dco_decode_opt_box_autoadd_i_64(dynamic raw);
@protected
BigInt? dco_decode_opt_box_autoadd_u_64(dynamic raw);
@@ -221,9 +239,15 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
SseDeserializer deserializer,
);
@protected
double sse_decode_box_autoadd_f_32(SseDeserializer deserializer);
@protected
int sse_decode_box_autoadd_i_32(SseDeserializer deserializer);
@protected
PlatformInt64 sse_decode_box_autoadd_i_64(SseDeserializer deserializer);
@protected
BigInt sse_decode_box_autoadd_u_64(SseDeserializer deserializer);
@@ -267,6 +291,11 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
@protected
BridgeClient sse_decode_bridge_client(SseDeserializer deserializer);
@protected
BridgeClientProfile sse_decode_bridge_client_profile(
SseDeserializer deserializer,
);
@protected
BridgeEffectOwner sse_decode_bridge_effect_owner(
SseDeserializer deserializer,
@@ -336,6 +365,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
@protected
PlatformInt64 sse_decode_i_64(SseDeserializer deserializer);
@protected
List<String> sse_decode_list_String(SseDeserializer deserializer);
@protected
List<BridgeAudioDevice> sse_decode_list_bridge_audio_device(
SseDeserializer deserializer,
@@ -368,9 +400,15 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
SseDeserializer deserializer,
);
@protected
double? sse_decode_opt_box_autoadd_f_32(SseDeserializer deserializer);
@protected
int? sse_decode_opt_box_autoadd_i_32(SseDeserializer deserializer);
@protected
PlatformInt64? sse_decode_opt_box_autoadd_i_64(SseDeserializer deserializer);
@protected
BigInt? sse_decode_opt_box_autoadd_u_64(SseDeserializer deserializer);
@@ -433,9 +471,18 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
SseSerializer serializer,
);
@protected
void sse_encode_box_autoadd_f_32(double self, SseSerializer serializer);
@protected
void sse_encode_box_autoadd_i_32(int self, SseSerializer serializer);
@protected
void sse_encode_box_autoadd_i_64(
PlatformInt64 self,
SseSerializer serializer,
);
@protected
void sse_encode_box_autoadd_u_64(BigInt self, SseSerializer serializer);
@@ -493,6 +540,12 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
@protected
void sse_encode_bridge_client(BridgeClient self, SseSerializer serializer);
@protected
void sse_encode_bridge_client_profile(
BridgeClientProfile self,
SseSerializer serializer,
);
@protected
void sse_encode_bridge_effect_owner(
BridgeEffectOwner self,
@@ -580,6 +633,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
@protected
void sse_encode_i_64(PlatformInt64 self, SseSerializer serializer);
@protected
void sse_encode_list_String(List<String> self, SseSerializer serializer);
@protected
void sse_encode_list_bridge_audio_device(
List<BridgeAudioDevice> self,
@@ -619,9 +675,18 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
SseSerializer serializer,
);
@protected
void sse_encode_opt_box_autoadd_f_32(double? self, SseSerializer serializer);
@protected
void sse_encode_opt_box_autoadd_i_32(int? self, SseSerializer serializer);
@protected
void sse_encode_opt_box_autoadd_i_64(
PlatformInt64? self,
SseSerializer serializer,
);
@protected
void sse_encode_opt_box_autoadd_u_64(BigInt? self, SseSerializer serializer);
@@ -313,47 +313,51 @@ class _AndroidAudioOutputPickerSheet extends StatelessWidget {
Widget build(BuildContext context) {
final l10n = AppL10n.of(context);
final theme = Theme.of(context);
final maxHeight = MediaQuery.sizeOf(context).height * 0.72;
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,
child: ConstrainedBox(
constraints: BoxConstraints(maxHeight: maxHeight),
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.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,
),
selected: device.isSelected,
onTap: device.isAvailableForCommunication
? () => Navigator.of(context).pop(device.id)
: null,
),
],
],
),
),
),
);
@@ -201,32 +201,19 @@ bool androidShowsLimiterControl(AudioProcessingConfigState state) {
/// Never leave the UI on the hidden disabled backend.
///
/// Desktop keeps Silero as the default, but still allows a user-chosen
/// WebRTC fallback when ONNX Runtime is unavailable.
/// Windows and Linux use Silero as the primary VAD; WebRTC is still
/// available internally as a runtime fallback.
rust.BridgeVadBackend normalizedVadBackend(
rust.BridgeVadBackend backend, {
bool? isWindows,
bool? isLinux,
bool onnxRuntimeAvailable = true,
}) {
final normalized = backend == rust.BridgeVadBackend.disabled
? rust.BridgeVadBackend.sileroOnnx
: backend;
final desktop =
(isWindows ?? Platform.isWindows) || (isLinux ?? Platform.isLinux);
if (desktop &&
!onnxRuntimeAvailable &&
normalized == rust.BridgeVadBackend.sileroOnnx) {
return rust.BridgeVadBackend.webrtcVad;
}
if (!desktop) {
return normalized;
}
return switch (normalized) {
rust.BridgeVadBackend.webrtcVad ||
rust.BridgeVadBackend.sileroOnnx => normalized,
_ => rust.BridgeVadBackend.sileroOnnx,
};
if (desktop) return rust.BridgeVadBackend.sileroOnnx;
return backend == rust.BridgeVadBackend.disabled
? rust.BridgeVadBackend.sileroOnnx
: backend;
}
rust.BridgeIosVoiceProcessingMode normalizedIosProcessingMode(
+188 -176
View File
@@ -1,3 +1,5 @@
import 'dart:async' show unawaited;
import 'package:flutter/material.dart';
import '../l10n/generated/app_localizations.dart';
@@ -9,12 +11,9 @@ import '../src/rust/api.dart' as rust;
import 'bbcode_text.dart';
const double _chatSidebarTileExtent = 92;
const double _chatSidebarIndicatorExtent = 76;
const double _chatSidebarIconExtent = 24;
const double _chatSidebarIconSize = 20;
const BorderRadius _chatSidebarIndicatorRadius = BorderRadius.all(
Radius.circular(20),
);
const double _chatSidebarCompactTileExtent = 76;
const double _chatSidebarCompactHeight = 84;
const double _chatMobileBreakpoint = 600;
/// One chat/activity message shown in the chat hub.
class ChatEntry {
@@ -616,12 +615,22 @@ class _ChatPageState extends State<ChatPage> {
final messages = _messages;
final currentChannelId = _currentChannelId;
final channelName = snapshotChannelName(snapshot, currentChannelId);
final l10n = AppL10n.of(context);
final detail = _ChatDetailView(
target: _selectedTarget,
clientName: _selectedClientName,
snapshot: snapshot,
messages: messages,
currentChannelId: currentChannelId,
channelName: channelName,
onTs3ServerLink: widget.onTs3ServerLink,
);
return Scaffold(
appBar: AppBar(
title: Text('Chat — ${snapshot.serverName}'),
title: Text('${l10n.chatAction} - ${snapshot.serverName}'),
actions: [
IconButton(
tooltip: 'Close chat',
tooltip: l10n.chatCloseAction,
icon: const Icon(Icons.close),
onPressed: _selectedPrivateClientId == null
? null
@@ -629,9 +638,10 @@ class _ChatPageState extends State<ChatPage> {
),
],
),
body: Row(
children: [
_ChatSidebar(
body: LayoutBuilder(
builder: (context, constraints) {
final sidebar = _ChatSidebar(
compact: constraints.maxWidth < _chatMobileBreakpoint,
selectedTarget: _selectedTarget,
privateChats: _privateChats,
onSelect: _selectTarget,
@@ -639,20 +649,24 @@ class _ChatPageState extends State<ChatPage> {
_closedPrivateChats.remove(id);
_selectTarget(rust.BridgeMessageTarget.client(id), name: name);
}),
),
const VerticalDivider(width: 1),
Expanded(
child: _ChatDetailView(
target: _selectedTarget,
clientName: _selectedClientName,
snapshot: snapshot,
messages: messages,
currentChannelId: currentChannelId,
channelName: channelName,
onTs3ServerLink: widget.onTs3ServerLink,
),
),
],
);
if (constraints.maxWidth < _chatMobileBreakpoint) {
return Column(
children: [
sidebar,
const Divider(height: 1),
Expanded(child: detail),
],
);
}
return Row(
children: [
sidebar,
const VerticalDivider(width: 1),
Expanded(child: detail),
],
);
},
),
);
}
@@ -740,12 +754,14 @@ class _PrivateChatItem {
class _ChatSidebar extends StatelessWidget {
const _ChatSidebar({
required this.compact,
required this.selectedTarget,
required this.privateChats,
required this.onSelect,
required this.onNewPrivateChat,
});
final bool compact;
final rust.BridgeMessageTarget selectedTarget;
final List<_PrivateChatItem> privateChats;
final void Function(rust.BridgeMessageTarget target, {String name}) onSelect;
@@ -753,64 +769,85 @@ class _ChatSidebar extends StatelessWidget {
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final dividerColor = theme.colorScheme.outlineVariant.withValues(
alpha: 0.45,
final l10n = AppL10n.of(context);
final fixedItems = [
_ChatSidebarItem(
compact: compact,
icon: Icons.dns_outlined,
label: 'Server',
selected: selectedTarget is rust.BridgeMessageTarget_Server,
onTap: () => onSelect(const rust.BridgeMessageTarget.server()),
),
_ChatSidebarItem(
compact: compact,
icon: Icons.tag,
label: 'Channel',
selected: selectedTarget is rust.BridgeMessageTarget_Channel,
onTap: () => onSelect(const rust.BridgeMessageTarget.channel()),
),
];
final privateItems = [
for (final chat in privateChats)
_ChatSidebarItem(
compact: compact,
icon: Icons.person_outline,
label: chat.name.isNotEmpty ? chat.name : 'Direct',
selected: switch (selectedTarget) {
rust.BridgeMessageTarget_Client(:final field0) => field0 == chat.id,
_ => false,
},
onTap: () => onSelect(
rust.BridgeMessageTarget.client(chat.id),
name: chat.name,
),
),
];
final addButton = Padding(
padding: EdgeInsets.all(compact ? 4 : 8),
child: IconButton.filledTonal(
tooltip: l10n.chatNewPrivateAction,
icon: const Icon(Icons.add),
onPressed: onNewPrivateChat,
),
);
return SizedBox(
width: _chatSidebarTileExtent,
child: Material(
color: theme.colorScheme.surfaceContainerLow,
child: Column(
final compactDivider = ColoredBox(
color: Theme.of(context).dividerColor,
child: const SizedBox(width: 1, height: double.infinity),
);
if (compact) {
return SizedBox(
height: _chatSidebarCompactHeight,
child: Row(
children: [
const SizedBox(height: 8),
_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()),
),
Divider(height: 1, indent: 12, endIndent: 12, color: dividerColor),
...fixedItems,
compactDivider,
Expanded(
child: ListView.builder(
padding: const EdgeInsets.symmetric(vertical: 6),
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,
),
);
},
),
),
Divider(height: 1, indent: 12, endIndent: 12, color: dividerColor),
Padding(
padding: const EdgeInsets.fromLTRB(8, 10, 8, 12),
child: IconButton.filledTonal(
tooltip: 'New private chat',
icon: const Icon(Icons.add),
onPressed: onNewPrivateChat,
child: ListView(
scrollDirection: Axis.horizontal,
padding: EdgeInsets.zero,
children: privateItems,
),
),
compactDivider,
addButton,
],
),
);
}
return SizedBox(
width: _chatSidebarTileExtent,
child: Column(
children: [
...fixedItems,
const Divider(height: 1),
Expanded(
child: ListView(padding: EdgeInsets.zero, children: privateItems),
),
const Divider(height: 1),
addButton,
],
),
);
}
@@ -818,12 +855,14 @@ class _ChatSidebar extends StatelessWidget {
class _ChatSidebarItem extends StatelessWidget {
const _ChatSidebarItem({
required this.compact,
required this.icon,
required this.label,
required this.selected,
required this.onTap,
});
final bool compact;
final IconData icon;
final String label;
final bool selected;
@@ -832,63 +871,57 @@ class _ChatSidebarItem extends StatelessWidget {
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final scheme = theme.colorScheme;
final indicatorColor = selected
? scheme.primaryContainer
: Colors.transparent;
final contentColor = selected
? scheme.onPrimaryContainer
: scheme.onSurfaceVariant;
final labelStyle = theme.textTheme.labelSmall?.copyWith(
color: contentColor,
height: 1.15,
fontWeight: selected ? FontWeight.w600 : FontWeight.w500,
);
return SizedBox(
width: _chatSidebarTileExtent,
height: _chatSidebarTileExtent,
child: Material(
color: Colors.transparent,
child: InkWell(
onTap: onTap,
borderRadius: _chatSidebarIndicatorRadius,
child: Center(
child: AnimatedContainer(
duration: const Duration(milliseconds: 180),
curve: Curves.easeOutCubic,
width: _chatSidebarIndicatorExtent,
height: _chatSidebarIndicatorExtent,
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8),
decoration: BoxDecoration(
color: indicatorColor,
borderRadius: _chatSidebarIndicatorRadius,
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
SizedBox(
width: _chatSidebarIconExtent,
height: _chatSidebarIconExtent,
child: Icon(
icon,
size: _chatSidebarIconSize,
color: contentColor,
),
final colorScheme = theme.colorScheme;
final fg = selected
? colorScheme.onSecondaryContainer
: colorScheme.onSurface;
final tileExtent = compact
? _chatSidebarCompactTileExtent
: _chatSidebarTileExtent;
return Material(
color: Colors.transparent,
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(8),
child: Ink(
width: tileExtent,
height: compact ? 72 : tileExtent,
decoration: BoxDecoration(
color: selected
? colorScheme.secondaryContainer
: Colors.transparent,
borderRadius: BorderRadius.circular(8),
),
child: Padding(
padding: EdgeInsets.symmetric(
horizontal: compact ? 6 : 8,
vertical: compact ? 8 : 10,
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
SizedBox(
width: 24,
height: 24,
child: Stack(
clipBehavior: Clip.none,
alignment: Alignment.center,
children: [Icon(icon, size: 18, color: fg)],
),
const SizedBox(height: 8),
SizedBox(
width: _chatSidebarIndicatorExtent - 16,
child: Text(
label,
maxLines: 2,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.center,
style: labelStyle,
),
),
const SizedBox(height: 8),
Text(
label,
maxLines: 2,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.center,
style: theme.textTheme.labelSmall?.copyWith(
color: fg,
height: 1.1,
fontWeight: selected ? FontWeight.w700 : FontWeight.w500,
),
],
),
),
],
),
),
),
@@ -926,6 +959,7 @@ class _ClientPickerDialogState extends State<_ClientPickerDialog> {
@override
Widget build(BuildContext context) {
final l10n = AppL10n.of(context);
final groups = filterChatClientPickerGroups(
channels: widget.channels,
clientsByChannel: widget.byChannel,
@@ -943,10 +977,10 @@ class _ClientPickerDialogState extends State<_ClientPickerDialog> {
padding: const EdgeInsets.fromLTRB(20, 20, 20, 8),
child: TextField(
controller: _searchCtl,
decoration: const InputDecoration(
hintText: 'Search clients...',
prefixIcon: Icon(Icons.search),
border: OutlineInputBorder(),
decoration: InputDecoration(
hintText: l10n.chatSearchClientsHint,
prefixIcon: const Icon(Icons.search),
border: const OutlineInputBorder(),
isDense: true,
),
onChanged: (v) => setState(() => _query = v),
@@ -1044,7 +1078,6 @@ class _ChatDetailViewState extends State<_ChatDetailView> {
final _scrollCtl = ScrollController();
int _lastRenderedMessageCount = -1;
rust.BridgeMessageTarget? _lastRenderedTarget;
bool _sending = false;
Iterable<ChatEntry> get _filtered {
if (widget.target is rust.BridgeMessageTarget_Channel) {
@@ -1074,51 +1107,30 @@ class _ChatDetailViewState extends State<_ChatDetailView> {
super.dispose();
}
Future<void> _send() async {
void _send() {
final text = _textCtl.text.trim();
if (text.isEmpty || !_canSend || _sending) return;
if (text.isEmpty || !_canSend) return;
_textCtl.clear();
setState(() => _sending = true);
try {
await rust.sendChatMessage(message: text, target: widget.target);
if (!mounted) return;
final ownId = widget.snapshot.ownClientId;
setState(() {
widget.messages.add(
ChatEntry(
senderId: ownId,
senderName: widget.target is rust.BridgeMessageTarget_Poke
? widget.clientName
: 'You',
message: text,
target: widget.target,
isSelf: true,
timestamp: DateTime.now(),
),
);
if (widget.messages.length > 200) {
widget.messages.removeRange(0, widget.messages.length - 200);
}
});
_scrollToBottom();
} catch (error) {
if (!mounted) return;
_textCtl.text = text;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
behavior: SnackBarBehavior.floating,
content: Text(
'Could not send message: $error',
maxLines: 3,
overflow: TextOverflow.ellipsis,
),
unawaited(rust.sendChatMessage(message: text, target: widget.target));
final ownId = widget.snapshot.ownClientId;
setState(() {
widget.messages.add(
ChatEntry(
senderId: ownId,
senderName: widget.target is rust.BridgeMessageTarget_Poke
? widget.clientName
: 'You',
message: text,
target: widget.target,
isSelf: true,
timestamp: DateTime.now(),
),
);
} finally {
if (mounted) {
setState(() => _sending = false);
if (widget.messages.length > 200) {
widget.messages.removeRange(0, widget.messages.length - 200);
}
}
});
_scrollToBottom();
}
void _scrollToBottom() {
@@ -0,0 +1,491 @@
import 'package:flutter/material.dart';
import '../l10n/generated/app_localizations.dart';
import '../src/rust/api.dart' as rust;
/// Bottom sheet that presents richer TeamSpeak client profile data.
class ClientInfoSheet extends StatefulWidget {
/// Construct a client info sheet.
const ClientInfoSheet({
super.key,
required this.clientName,
required this.loadProfile,
});
/// Display name used while the profile request is loading.
final String clientName;
/// Fetch richer profile data from the active protocol connection.
final Future<rust.BridgeClientProfile> Function() loadProfile;
@override
State<ClientInfoSheet> createState() => _ClientInfoSheetState();
}
class _ClientInfoSheetState extends State<ClientInfoSheet> {
late Future<rust.BridgeClientProfile> _profileFuture;
@override
void initState() {
super.initState();
_profileFuture = widget.loadProfile();
}
void _retry() {
setState(() {
_profileFuture = widget.loadProfile();
});
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return SafeArea(
top: false,
child: Padding(
padding: const EdgeInsets.fromLTRB(20, 10, 20, 20),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 40,
height: 4,
decoration: BoxDecoration(
color: theme.colorScheme.onSurfaceVariant.withValues(
alpha: 0.34,
),
borderRadius: BorderRadius.circular(999),
),
),
const SizedBox(height: 16),
Expanded(
child: FutureBuilder<rust.BridgeClientProfile>(
future: _profileFuture,
builder: (context, snapshot) {
if (snapshot.connectionState != ConnectionState.done) {
return _ClientInfoLoading(name: widget.clientName);
}
if (snapshot.hasError || !snapshot.hasData) {
return _ClientInfoError(
name: widget.clientName,
onRetry: _retry,
);
}
return _ClientInfoContent(profile: snapshot.data!);
},
),
),
],
),
),
);
}
}
class _ClientInfoLoading extends StatelessWidget {
const _ClientInfoLoading({required this.name});
final String name;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final l10n = AppL10n.of(context);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_ClientInfoHeader(name: name, subtitle: l10n.clientInfoFetchingProfile),
const SizedBox(height: 24),
LinearProgressIndicator(
minHeight: 3,
borderRadius: BorderRadius.circular(999),
),
const SizedBox(height: 16),
Text(l10n.clientInfoLoadingProfile, style: theme.textTheme.bodyMedium),
],
);
}
}
class _ClientInfoError extends StatelessWidget {
const _ClientInfoError({required this.name, required this.onRetry});
final String name;
final VoidCallback onRetry;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final l10n = AppL10n.of(context);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_ClientInfoHeader(
name: name,
subtitle: l10n.clientInfoProfileUnavailable,
),
const SizedBox(height: 24),
Icon(Icons.info_outline, color: theme.colorScheme.error, size: 32),
const SizedBox(height: 12),
Text(
l10n.clientInfoProfileUnavailableBody,
style: theme.textTheme.bodyMedium,
),
const SizedBox(height: 16),
FilledButton.icon(
onPressed: onRetry,
icon: const Icon(Icons.refresh),
label: Text(l10n.retryAction),
),
],
);
}
}
class _ClientInfoContent extends StatelessWidget {
const _ClientInfoContent({required this.profile});
final rust.BridgeClientProfile profile;
@override
Widget build(BuildContext context) {
final l10n = AppL10n.of(context);
return ListView(
children: [
_ClientInfoHeader(
name: profile.name,
subtitle: _joinNonEmpty([
profile.platform,
profile.version,
profile.countryCode,
]),
),
const SizedBox(height: 20),
_InfoSection(
title: l10n.clientInfoIdentitySection,
rows: [
_InfoRowData(l10n.clientInfoClientId, profile.id.toString()),
_InfoRowData(
l10n.clientInfoDatabaseId,
_formatBigInt(profile.databaseId, l10n),
),
_InfoRowData(
l10n.clientInfoUniqueId,
_emptyAsHidden(profile.uniqueId, l10n),
),
_InfoRowData(
l10n.clientInfoDescription,
_emptyAsHidden(profile.description, l10n),
),
_InfoRowData(
l10n.clientInfoAvatar,
_emptyAsNone(profile.avatarPath, l10n),
),
],
),
_InfoSection(
title: l10n.clientInfoMembershipSection,
rows: [
_InfoRowData(
l10n.clientInfoServerGroups,
profile.serverGroups.isEmpty
? l10n.clientInfoUnknown
: profile.serverGroups.join(', '),
),
_InfoRowData(
l10n.clientInfoChannelGroup,
_emptyAsHidden(profile.channelGroup, l10n),
),
_InfoRowData(l10n.clientInfoChannelId, profile.channel.toString()),
],
),
_InfoSection(
title: l10n.clientInfoConnectionSection,
rows: [
_InfoRowData(
l10n.clientInfoOnline,
_formatSeconds(profile.onlineSeconds, l10n),
),
_InfoRowData(
l10n.clientInfoIdle,
_formatMilliseconds(profile.idleMilliseconds, l10n),
),
_InfoRowData(
l10n.clientInfoPing,
_formatMilliseconds(profile.pingMilliseconds, l10n),
),
_InfoRowData(
l10n.clientInfoAddress,
_emptyAsHidden(profile.clientAddress, l10n),
),
_InfoRowData(
l10n.clientInfoPacketLossClientToServer,
_formatLoss(profile.packetLossClientToServerTotal),
),
_InfoRowData(
l10n.clientInfoPacketLossServerToClient,
_formatLoss(profile.packetLossServerToClientTotal),
),
],
),
_InfoSection(
title: l10n.clientInfoHistorySection,
rows: [
_InfoRowData(
l10n.clientInfoFirstConnected,
_formatUnix(profile.createdUnixSeconds, l10n),
),
_InfoRowData(
l10n.clientInfoLastConnected,
_formatUnix(profile.lastConnectedUnixSeconds, l10n),
),
_InfoRowData(
l10n.clientInfoConnections,
_formatBigInt(profile.connectionsTotal, l10n),
),
],
),
_InfoSection(
title: l10n.clientInfoTransferSection,
rows: [
_InfoRowData(
l10n.clientInfoDownloadedMonth,
_formatBytes(profile.bytesDownloadedMonth),
),
_InfoRowData(
l10n.clientInfoUploadedMonth,
_formatBytes(profile.bytesUploadedMonth),
),
_InfoRowData(
l10n.clientInfoDownloadedTotal,
_formatBytes(profile.bytesDownloadedTotal),
),
_InfoRowData(
l10n.clientInfoUploadedTotal,
_formatBytes(profile.bytesUploadedTotal),
),
],
),
],
);
}
}
class _ClientInfoHeader extends StatelessWidget {
const _ClientInfoHeader({required this.name, required this.subtitle});
final String name;
final String subtitle;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
CircleAvatar(
radius: 28,
backgroundColor: theme.colorScheme.primaryContainer,
foregroundColor: theme.colorScheme.onPrimaryContainer,
child: Text(
_initials(name),
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w700,
),
),
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
name,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.w700,
),
),
if (subtitle.isNotEmpty) ...[
const SizedBox(height: 4),
Text(
subtitle,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
],
],
),
),
],
);
}
}
class _InfoSection extends StatelessWidget {
const _InfoSection({required this.title, required this.rows});
final String title;
final List<_InfoRowData> rows;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Padding(
padding: const EdgeInsets.only(bottom: 20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: theme.textTheme.labelLarge?.copyWith(
color: theme.colorScheme.primary,
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 8),
DecoratedBox(
decoration: BoxDecoration(
border: Border(
top: BorderSide(color: theme.dividerColor),
bottom: BorderSide(color: theme.dividerColor),
),
),
child: Column(
children: [
for (var i = 0; i < rows.length; i++) ...[
_InfoRow(row: rows[i]),
if (i != rows.length - 1) const Divider(height: 1),
],
],
),
),
],
),
);
}
}
class _InfoRow extends StatelessWidget {
const _InfoRow({required this.row});
final _InfoRowData row;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Padding(
padding: const EdgeInsets.symmetric(vertical: 11),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: 132,
child: Text(
row.label,
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
),
const SizedBox(width: 12),
Expanded(
child: SelectableText(
row.value,
style: theme.textTheme.bodyMedium?.copyWith(
fontFeatures: const [FontFeature.tabularFigures()],
),
),
),
],
),
);
}
}
class _InfoRowData {
const _InfoRowData(this.label, this.value);
final String label;
final String value;
}
String _initials(String value) {
final trimmed = value.trim();
if (trimmed.isEmpty) return '?';
final parts = trimmed.split(RegExp(r'\s+')).where((p) => p.isNotEmpty);
final chars = parts.take(2).map((p) => p.characters.first.toUpperCase());
return chars.join();
}
String _joinNonEmpty(Iterable<String> values) {
return values.where((value) => value.trim().isNotEmpty).join(' / ');
}
String _emptyAsHidden(String value, AppL10n l10n) =>
value.trim().isEmpty ? l10n.clientInfoHidden : value;
String _emptyAsNone(String value, AppL10n l10n) =>
value.trim().isEmpty ? l10n.clientInfoNone : value;
String _formatBigInt(BigInt? value, AppL10n l10n) =>
value?.toString() ?? l10n.clientInfoUnknown;
String _formatUnix(Object? seconds, AppL10n l10n) {
final raw = _intFromPlatform(seconds);
if (raw == null || raw <= 0) return l10n.clientInfoUnknown;
final date = DateTime.fromMillisecondsSinceEpoch(
raw * 1000,
isUtc: true,
).toLocal();
return date.toString().split('.').first;
}
String _formatSeconds(Object? seconds, AppL10n l10n) {
final raw = _intFromPlatform(seconds);
if (raw == null) return l10n.clientInfoUnknown;
if (raw < 60) return '$raw s';
final minutes = raw ~/ 60;
final hours = minutes ~/ 60;
if (hours > 0) {
return '${hours}h ${minutes % 60}m ${raw % 60}s';
}
return '${minutes}m ${raw % 60}s';
}
String _formatMilliseconds(Object? milliseconds, AppL10n l10n) {
final raw = _intFromPlatform(milliseconds);
if (raw == null) return l10n.clientInfoUnknown;
if (raw < 1000) return '$raw ms';
return '${(raw / 1000).toStringAsFixed(2)} s';
}
String _formatBytes(BigInt? value) {
if (value == null) return 'Unknown';
final bytes = value.toDouble();
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
var amount = bytes;
var unitIndex = 0;
while (amount >= 1024 && unitIndex < units.length - 1) {
amount /= 1024;
unitIndex++;
}
final digits = unitIndex == 0 ? 0 : 1;
return '${amount.toStringAsFixed(digits)} ${units[unitIndex]}';
}
String _formatLoss(double? value) {
if (value == null) return 'Unknown';
return '${(value * 100).toStringAsFixed(2)}%';
}
int? _intFromPlatform(Object? value) {
if (value == null) return null;
if (value is int) return value;
if (value is BigInt) return value.toInt();
return int.tryParse(value.toString());
}
@@ -65,7 +65,6 @@ class _ConnectFormState extends State<ConnectForm> {
keyboardType: TextInputType.url,
textCapitalization: TextCapitalization.none,
textInputAction: TextInputAction.next,
onSubmitted: (_) => _nickFocus.requestFocus(),
autocorrect: false,
enableSuggestions: false,
inputFormatters: [
@@ -90,7 +89,6 @@ class _ConnectFormState extends State<ConnectForm> {
focusNode: _nickFocus,
onTapOutside: _onTapOutside,
textInputAction: TextInputAction.next,
onSubmitted: (_) => _passwordFocus.requestFocus(),
autocorrect: false,
enableSuggestions: false,
decoration: InputDecoration(
@@ -105,7 +103,6 @@ class _ConnectFormState extends State<ConnectForm> {
onTapOutside: _onTapOutside,
obscureText: true,
textInputAction: TextInputAction.done,
onSubmitted: (_) => widget.onConnect(),
autocorrect: false,
enableSuggestions: false,
decoration: InputDecoration(
@@ -115,22 +112,37 @@ class _ConnectFormState extends State<ConnectForm> {
),
),
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(
LayoutBuilder(
builder: (context, constraints) {
const stackedActionsMaxWidth = 400.0;
final connectButton = FilledButton.icon(
icon: const Icon(Icons.login),
label: Text(l10n.connectAction),
onPressed: widget.onConnect,
);
final bookmarkButton = OutlinedButton.icon(
icon: const Icon(Icons.bookmark_add_outlined),
label: Text(l10n.bookmarkAddAction),
onPressed: widget.onAddBookmark,
),
],
);
if (constraints.maxWidth <= stackedActionsMaxWidth) {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
connectButton,
const SizedBox(height: 8),
bookmarkButton,
],
);
}
return Row(
children: [
Expanded(child: connectButton),
const SizedBox(width: 8),
Flexible(child: bookmarkButton),
],
);
},
),
],
);
@@ -4,9 +4,6 @@ import 'package:flutter/services.dart';
import '../l10n/generated/app_localizations.dart';
import '../src/rust/api.dart' as rust;
const int pttMouseBackButtonBitmask = 0x08;
const int pttMouseForwardButtonBitmask = 0x10;
/// Translate a [LogicalKeyboardKey] into the platform-neutral label
/// stored by the PTT binding flow.
String? pttDisplayLabelForKey(LogicalKeyboardKey k) {
@@ -49,27 +46,6 @@ String? pttDisplayLabelForKey(LogicalKeyboardKey k) {
return fallback;
}
String? pttMouseSideButtonPlatformKeyForLogicalKey(LogicalKeyboardKey key) {
return switch (key) {
LogicalKeyboardKey.browserBack ||
LogicalKeyboardKey.goBack => 'mouse-side-button:$pttMouseBackButtonBitmask',
LogicalKeyboardKey.browserForward =>
'mouse-side-button:$pttMouseForwardButtonBitmask',
_ => null,
};
}
String? pttMouseSideButtonPlatformKeyForButtons(int buttons) {
if ((buttons & pttMouseBackButtonBitmask) == pttMouseBackButtonBitmask) {
return 'mouse-side-button:$pttMouseBackButtonBitmask';
}
if ((buttons & pttMouseForwardButtonBitmask) ==
pttMouseForwardButtonBitmask) {
return 'mouse-side-button:$pttMouseForwardButtonBitmask';
}
return null;
}
/// Result of a successful PTT binding capture.
class CapturedBinding {
const CapturedBinding({required this.inputClass, required this.platformKey});
@@ -205,16 +181,6 @@ class _PttBindingCaptureDialogState extends State<PttBindingCaptureDialog> {
KeyEventResult _onKeyEvent(FocusNode node, KeyEvent event) {
if (event is! KeyDownEvent) return KeyEventResult.ignored;
final mouseSideButton = pttMouseSideButtonPlatformKeyForLogicalKey(
event.logicalKey,
);
if (mouseSideButton != null) {
setState(() {
_captured = mouseSideButton;
_capturedClass = rust.BridgePttInputClass.mouseSideButton;
});
return KeyEventResult.handled;
}
final label = pttDisplayLabelForKey(event.logicalKey);
if (label == null) return KeyEventResult.ignored;
setState(() {
@@ -224,9 +190,9 @@ class _PttBindingCaptureDialogState extends State<PttBindingCaptureDialog> {
return KeyEventResult.handled;
}
void _captureMouseSideButton(String platformKey) {
void _captureMouseSideButton(int button) {
setState(() {
_captured = platformKey;
_captured = 'mouse-side-button:$button';
_capturedClass = rust.BridgePttInputClass.mouseSideButton;
});
}
@@ -237,57 +203,59 @@ class _PttBindingCaptureDialogState extends State<PttBindingCaptureDialog> {
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) {
final platformKey = pttMouseSideButtonPlatformKeyForButtons(
e.buttons,
);
if (platformKey != null) {
_captureMouseSideButton(platformKey);
}
},
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,
content: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 360),
child: SingleChildScrollView(
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,
),
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),
Container(
width: double.infinity,
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,
const SizedBox(height: 12),
Text(
l10n.pttConfigurePrivacyNote,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
),
],
],
),
),
),
),
@@ -54,43 +54,48 @@ class PttCapabilityBadge extends StatelessWidget {
showModalBottomSheet<void>(
context: context,
showDragHandle: true,
isScrollControlled: true,
builder: (sheetContext) {
final theme = Theme.of(sheetContext);
final maxHeight = MediaQuery.sizeOf(sheetContext).height * 0.72;
return SafeArea(
child: Padding(
padding: const EdgeInsets.fromLTRB(20, 4, 20, 24),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
l10n.pttCapabilityExplainTitle,
style: theme.textTheme.titleMedium,
),
const SizedBox(height: 12),
Text(
l10n.pttCapabilityExplainFocusedHeading,
style: theme.textTheme.titleSmall,
),
const SizedBox(height: 4),
Text(
l10n.pttCapabilityExplainFocusedBody,
style: theme.textTheme.bodyMedium,
),
const SizedBox(height: 16),
Text(
_explainBodyForPlatform(l10n),
style: theme.textTheme.bodyMedium,
),
const SizedBox(height: 16),
Align(
alignment: AlignmentDirectional.centerEnd,
child: TextButton(
onPressed: () => Navigator.of(sheetContext).pop(),
child: Text(l10n.closeAction),
child: ConstrainedBox(
constraints: BoxConstraints(maxHeight: maxHeight),
child: SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(20, 4, 20, 24),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
l10n.pttCapabilityExplainTitle,
style: theme.textTheme.titleMedium,
),
),
],
const SizedBox(height: 12),
Text(
l10n.pttCapabilityExplainFocusedHeading,
style: theme.textTheme.titleSmall,
),
const SizedBox(height: 4),
Text(
l10n.pttCapabilityExplainFocusedBody,
style: theme.textTheme.bodyMedium,
),
const SizedBox(height: 16),
Text(
_explainBodyForPlatform(l10n),
style: theme.textTheme.bodyMedium,
),
const SizedBox(height: 16),
Align(
alignment: AlignmentDirectional.centerEnd,
child: TextButton(
onPressed: () => Navigator.of(sheetContext).pop(),
child: Text(l10n.closeAction),
),
),
],
),
),
),
);
@@ -1,5 +1,3 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
@@ -8,17 +6,10 @@ import '../services/channel_spacer.dart';
import '../services/link_trust_service.dart';
import '../services/snapshot_state_mapper.dart';
import '../services/ts3_server_link.dart';
import '../services/ui_preferences_service.dart';
import '../src/rust/api.dart' as rust;
import 'bbcode_text.dart';
import 'talk_power_warning.dart';
typedef ClientPlaybackPreferenceChanged =
Future<void> Function(
rust.BridgeClient client,
ClientPlaybackPreference preference,
);
/// Connected-server snapshot with welcome text, channels, and clients.
class SnapshotView extends StatefulWidget {
/// Construct a snapshot view.
@@ -34,8 +25,10 @@ class SnapshotView extends StatefulWidget {
required this.canJoinVoiceChannel,
required this.onJoinChannel,
required this.onJoinChannelWithPassword,
this.clientPlaybackPreferences = const {},
this.onClientPlaybackPreferenceChanged,
this.enableClientLongPressMenu = false,
this.onOpenClientInfo,
this.onOpenClientChat,
this.onOpenClientPoke,
this.onTs3ServerLink,
});
@@ -69,11 +62,17 @@ class SnapshotView extends StatefulWidget {
/// Join a password-protected channel.
final ValueChanged<rust.BridgeChannel> onJoinChannelWithPassword;
/// Persisted per-client playback preferences keyed by TeamSpeak UID.
final Map<String, ClientPlaybackPreference> clientPlaybackPreferences;
/// True on touch-only mobile hosts where long-press opens client actions.
final bool enableClientLongPressMenu;
/// Apply an updated per-client playback preference.
final ClientPlaybackPreferenceChanged? onClientPlaybackPreferenceChanged;
/// Open richer profile details for a non-self client.
final ValueChanged<rust.BridgeClient>? onOpenClientInfo;
/// Open a direct chat with a non-self client.
final ValueChanged<rust.BridgeClient>? onOpenClientChat;
/// Open a poke composer for a non-self client.
final ValueChanged<rust.BridgeClient>? onOpenClientPoke;
/// Handle TeamSpeak server links embedded in server-provided text.
final Ts3ServerLinkHandler? onTs3ServerLink;
@@ -88,11 +87,10 @@ class _SnapshotViewState extends State<SnapshotView> {
static const _channelIconColumnWidth = 24.0;
static const _channelTextGap = 8.0;
static const _userRowStartIndent = 32.0;
static const _clientPlaybackVolumePresets = [0.25, 0.5, 1.0, 2.0, 4.0];
final _scrollController = ScrollController();
final Map<BigInt, bool> _channelExpandedById = {};
bool _welcomeExpanded = true;
bool _welcomeExpanded = false;
double _welcomeHeight = 0;
final _welcomeKey = GlobalKey();
@@ -293,6 +291,12 @@ class _SnapshotViewState extends State<SnapshotView> {
fontWeight: FontWeight.w600,
)
: null;
final isSelf = client.id == widget.snapshot.ownClientId;
final canOpenPeerActions =
!isSelf &&
(widget.onOpenClientChat != null || widget.onOpenClientPoke != null);
final canOpenClientMenu =
widget.onOpenClientInfo != null || canOpenPeerActions;
final decoration = status.isSpeaking
? BoxDecoration(
@@ -311,40 +315,90 @@ class _SnapshotViewState extends State<SnapshotView> {
],
)
: null;
final canAdjustPlayback = _canAdjustClientPlayback(client);
Offset? secondaryTapPosition;
return Padding(
final tile = Padding(
padding: EdgeInsets.only(
left: channelIndent + _userRowStartIndent,
right: 8,
),
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onLongPress: canAdjustPlayback
? () => unawaited(_showClientPlaybackMenu(client, withHaptic: true))
: null,
onSecondaryTapDown: canAdjustPlayback
? (details) => secondaryTapPosition = details.globalPosition
: null,
onSecondaryTap: canAdjustPlayback
? () => unawaited(
_showClientPlaybackMenu(client, anchor: secondaryTapPosition),
)
: null,
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),
),
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),
),
),
);
if (!canOpenClientMenu) return tile;
return GestureDetector(
behavior: HitTestBehavior.opaque,
onSecondaryTapDown: (details) =>
_showClientMenu(client, details.globalPosition),
onLongPressStart: widget.enableClientLongPressMenu
? (details) => _showClientMenu(client, details.globalPosition)
: null,
child: tile,
);
}
Future<void> _showClientMenu(
rust.BridgeClient client,
Offset globalPosition,
) async {
final overlay = Overlay.of(context).context.findRenderObject();
if (overlay is! RenderBox) return;
final isSelf = client.id == widget.snapshot.ownClientId;
final selected = await showMenu<_ClientMenuAction>(
context: context,
position: RelativeRect.fromRect(
Rect.fromLTWH(globalPosition.dx, globalPosition.dy, 0, 0),
Offset.zero & overlay.size,
),
items: [
if (widget.onOpenClientInfo != null)
PopupMenuItem(
value: _ClientMenuAction.info,
child: ListTile(
dense: true,
leading: const Icon(Icons.info_outline),
title: Text(AppL10n.of(context).clientInfoAction),
),
),
if (!isSelf && widget.onOpenClientChat != null)
PopupMenuItem(
value: _ClientMenuAction.directMessage,
child: ListTile(
dense: true,
leading: const Icon(Icons.chat_bubble_outline),
title: Text(AppL10n.of(context).chatDirectMessageAction),
),
),
if (!isSelf && widget.onOpenClientPoke != null)
PopupMenuItem(
value: _ClientMenuAction.poke,
child: ListTile(
dense: true,
leading: const Icon(Icons.notifications_active_outlined),
title: Text(AppL10n.of(context).chatPokeAction),
),
),
],
);
if (!mounted || selected == null) return;
switch (selected) {
case _ClientMenuAction.info:
widget.onOpenClientInfo?.call(client);
case _ClientMenuAction.directMessage:
widget.onOpenClientChat?.call(client);
case _ClientMenuAction.poke:
widget.onOpenClientPoke?.call(client);
}
}
Widget _expandButton(
@@ -386,81 +440,6 @@ class _SnapshotViewState extends State<SnapshotView> {
});
}
bool _canAdjustClientPlayback(rust.BridgeClient client) {
return widget.onClientPlaybackPreferenceChanged != null &&
client.id != widget.snapshot.ownClientId &&
!client.isServerQuery &&
client.uid.isNotEmpty;
}
ClientPlaybackPreference _clientPlaybackPreference(rust.BridgeClient client) {
return widget.clientPlaybackPreferences[client.uid] ??
const ClientPlaybackPreference();
}
Future<void> _showClientPlaybackMenu(
rust.BridgeClient client, {
Offset? anchor,
bool withHaptic = false,
}) async {
if (!_canAdjustClientPlayback(client)) return;
final onChanged = widget.onClientPlaybackPreferenceChanged;
if (onChanged == null) return;
if (withHaptic) {
unawaited(HapticFeedback.selectionClick().catchError((_) {}));
}
if (!mounted) return;
final preference = _clientPlaybackPreference(client);
final overlay = Overlay.of(context).context.findRenderObject() as RenderBox;
final position = anchor ?? overlay.size.center(Offset.zero);
final selected = await showMenu<_ClientPlaybackMenuAction>(
context: context,
position: RelativeRect.fromLTRB(
position.dx,
position.dy,
overlay.size.width - position.dx,
overlay.size.height - position.dy,
),
items: [
PopupMenuItem<_ClientPlaybackMenuAction>(
value: const _ToggleMuteClientPlaybackMenuAction(),
child: ListTile(
dense: true,
contentPadding: EdgeInsets.zero,
leading: Icon(
preference.muted ? Icons.volume_off : Icons.volume_up,
),
title: Text(preference.muted ? 'Unmute playback' : 'Mute playback'),
subtitle: Text(client.name),
),
),
const PopupMenuDivider(),
..._clientPlaybackVolumePresets.map(
(preset) => CheckedPopupMenuItem<_ClientPlaybackMenuAction>(
value: _VolumeClientPlaybackMenuAction(preset),
checked:
!preference.muted && (preference.volume - preset).abs() < 0.001,
child: Text('Volume ${(preset * 100).round()}%'),
),
),
],
);
if (selected == null || !mounted) return;
final next = switch (selected) {
_ToggleMuteClientPlaybackMenuAction() => preference.copyWith(
muted: !preference.muted,
),
_VolumeClientPlaybackMenuAction(:final volume) => preference.copyWith(
volume: volume,
muted: false,
),
};
await onChanged(client, next);
}
({Widget icon, bool isSpeaking}) _clientVoiceStatusIcon(
ThemeData theme,
rust.BridgeClient client,
@@ -530,6 +509,8 @@ class _SnapshotViewState extends State<SnapshotView> {
}
}
enum _ClientMenuAction { info, directMessage, poke }
class _SpacerChannelContent extends StatelessWidget {
const _SpacerChannelContent({required this.spacer});
@@ -673,20 +654,6 @@ class _ChannelTreeNode {
final List<_ChannelTreeNode> children = [];
}
sealed class _ClientPlaybackMenuAction {
const _ClientPlaybackMenuAction();
}
class _ToggleMuteClientPlaybackMenuAction extends _ClientPlaybackMenuAction {
const _ToggleMuteClientPlaybackMenuAction();
}
class _VolumeClientPlaybackMenuAction extends _ClientPlaybackMenuAction {
const _VolumeClientPlaybackMenuAction(this.volume);
final double volume;
}
_ChannelTree _buildChannelTree(List<rust.BridgeChannel> channels) {
final byParent = <BigInt, List<rust.BridgeChannel>>{};
final knownIds = {for (final channel in channels) channel.id};
@@ -757,7 +724,7 @@ class _WelcomeMessageTile extends StatelessWidget {
),
const SizedBox(width: 4),
Text(
'Server welcome message',
AppL10n.of(context).serverWelcomeHeading,
style: theme.textTheme.labelMedium?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
@@ -766,20 +733,22 @@ class _WelcomeMessageTile extends StatelessWidget {
),
),
),
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,
AnimatedSwitcher(
duration: const Duration(milliseconds: 200),
child: expanded
? Padding(
key: const ValueKey('welcome-expanded'),
padding: const EdgeInsets.fromLTRB(8, 0, 8, 8),
child: BbCodeText(
welcomeMessage,
linkTrust: LinkTrustService.instance,
onTs3ServerLink: onTs3ServerLink,
),
)
: const SizedBox(
key: ValueKey('welcome-collapsed'),
width: double.infinity,
),
),
],
),
@@ -1,468 +0,0 @@
import 'dart:async' show unawaited;
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:url_launcher/url_launcher.dart';
import '../services/startup_dependency_check.dart';
class StartupDependencyGate extends StatefulWidget {
const StartupDependencyGate({
required this.child,
this.checker = checkStartupDependencies,
this.logger = logStartupDependencyIssues,
super.key,
});
final Widget child;
final Future<StartupDependencyCheckResult> Function() checker;
final Future<void> Function(StartupDependencyCheckResult result) logger;
@override
State<StartupDependencyGate> createState() => _StartupDependencyGateState();
}
class StartupDependencyScope extends InheritedWidget {
const StartupDependencyScope({
required this.result,
required super.child,
super.key,
});
final StartupDependencyCheckResult result;
static StartupDependencyCheckResult? maybeOf(BuildContext context) {
return context
.dependOnInheritedWidgetOfExactType<StartupDependencyScope>()
?.result;
}
@override
bool updateShouldNotify(StartupDependencyScope oldWidget) {
return result != oldWidget.result;
}
}
class _StartupDependencyGateState extends State<StartupDependencyGate> {
late Future<StartupDependencyCheckResult> _future;
bool _dismissedForSession = false;
String? _lastLoggedIssueSignature;
@override
void initState() {
super.initState();
_future = widget.checker();
}
void _recheck() {
setState(() {
_future = widget.checker();
_dismissedForSession = false;
});
}
@override
Widget build(BuildContext context) {
if (_dismissedForSession) {
return FutureBuilder<StartupDependencyCheckResult>(
future: _future,
builder: (context, snapshot) {
return StartupDependencyScope(
result:
snapshot.data ??
const StartupDependencyCheckResult(
issues: [],
platformLabel: 'default',
),
child: widget.child,
);
},
);
}
return FutureBuilder<StartupDependencyCheckResult>(
future: _future,
builder: (context, snapshot) {
if (snapshot.connectionState != ConnectionState.done) {
return const _StartupCheckLoadingView();
}
final result = snapshot.data;
if (result == null || !result.hasIssues) {
_lastLoggedIssueSignature = null;
return StartupDependencyScope(
result:
result ??
const StartupDependencyCheckResult(
issues: [],
platformLabel: 'default',
),
child: widget.child,
);
}
_logIssueScreenShown(result);
return StartupDependencyScope(
result: result,
child: StartupDependencyScreen(
result: result,
onContinue: () {
setState(() {
_dismissedForSession = true;
});
},
onRecheck: _recheck,
),
);
},
);
}
void _logIssueScreenShown(StartupDependencyCheckResult result) {
final signature = [
result.platformLabel,
for (final issue in result.issues)
'${issue.id}:${issue.isRequired ? 'required' : 'recommended'}',
].join('|');
if (_lastLoggedIssueSignature == signature) {
return;
}
_lastLoggedIssueSignature = signature;
unawaited(widget.logger(result));
}
}
class StartupDependencyScreen extends StatelessWidget {
const StartupDependencyScreen({
required this.result,
required this.onContinue,
required this.onRecheck,
super.key,
});
final StartupDependencyCheckResult result;
final VoidCallback onContinue;
final VoidCallback onRecheck;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final hasBlockingIssues = result.hasBlockingIssues;
final content = Column(
children: [
Icon(
hasBlockingIssues
? Icons.warning_amber_rounded
: Icons.info_outline_rounded,
size: 52,
color: hasBlockingIssues
? theme.colorScheme.error
: theme.colorScheme.primary,
),
const SizedBox(height: 16),
Text(
'Finish Linux setup',
style: theme.textTheme.headlineMedium,
textAlign: TextAlign.center,
),
const SizedBox(height: 12),
Text(
hasBlockingIssues
? 'Chanora started, but some Linux runtime packages are still missing. Install them, then recheck.'
: 'Chanora started, but a few optional Linux runtime components are still missing. You can install them now or continue with limited functionality.',
style: theme.textTheme.bodyLarge,
textAlign: TextAlign.center,
),
const SizedBox(height: 8),
Text(
'Detected platform: ${result.platformLabel}',
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 24),
...result.issues.map((issue) => _DependencyIssueCard(issue: issue)),
const SizedBox(height: 16),
Wrap(
alignment: WrapAlignment.center,
spacing: 12,
runSpacing: 12,
children: [
FilledButton.icon(
onPressed: onRecheck,
icon: const Icon(Icons.refresh_rounded),
label: const Text('Recheck'),
),
OutlinedButton.icon(
onPressed: onContinue,
icon: const Icon(Icons.arrow_forward_rounded),
label: Text(
hasBlockingIssues
? 'Continue with limited mode'
: 'Continue anyway',
),
),
],
),
],
);
return Scaffold(
body: SafeArea(
child: Scrollbar(
child: ListView(
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 24),
children: [
Align(
alignment: Alignment.topCenter,
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 880),
child: content,
),
),
],
),
),
),
);
}
}
class _DependencyIssueCard extends StatelessWidget {
const _DependencyIssueCard({required this.issue});
final StartupDependencyIssue issue;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final scheme = theme.colorScheme;
final containerColor = issue.isRequired
? scheme.errorContainer
: scheme.secondaryContainer;
final onContainerColor = issue.isRequired
? scheme.onErrorContainer
: scheme.onSecondaryContainer;
return Card(
margin: const EdgeInsets.only(bottom: 16),
color: containerColor,
child: Padding(
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(
issue.isRequired
? Icons.error_outline_rounded
: Icons.settings_suggest_rounded,
color: onContainerColor,
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
issue.title,
style: theme.textTheme.titleMedium?.copyWith(
color: onContainerColor,
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 4),
Text(
issue.summary,
style: theme.textTheme.bodyMedium?.copyWith(
color: onContainerColor,
),
),
],
),
),
const SizedBox(width: 12),
_SeverityBadge(issue: issue),
],
),
if (issue.details.isNotEmpty) ...[
const SizedBox(height: 16),
...issue.details.map(
(detail) => Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Text(
'$detail',
style: theme.textTheme.bodyMedium?.copyWith(
color: onContainerColor,
),
),
),
),
],
if (issue.installHints.isNotEmpty) ...[
const SizedBox(height: 8),
Text(
'Install help',
style: theme.textTheme.titleSmall?.copyWith(
color: onContainerColor,
),
),
const SizedBox(height: 12),
...issue.installHints.map(
(hint) => _InstallHintTile(
hint: hint,
foregroundColor: onContainerColor,
),
),
],
],
),
),
);
}
}
class _InstallHintTile extends StatelessWidget {
const _InstallHintTile({required this.hint, required this.foregroundColor});
final StartupInstallHint hint;
final Color foregroundColor;
@override
Widget build(BuildContext context) {
final messenger = ScaffoldMessenger.of(context);
final isUrl = _isWebUrl(hint.command);
final VoidCallback? onTap = isUrl
? () => unawaited(_openUrl(context, Uri.parse(hint.command)))
: null;
return InkWell(
borderRadius: BorderRadius.circular(16),
onTap: onTap,
child: Container(
margin: const EdgeInsets.only(bottom: 12),
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(16),
color: foregroundColor.withValues(alpha: 0.08),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Text(
hint.label,
style: Theme.of(context).textTheme.labelLarge?.copyWith(
color: foregroundColor,
fontWeight: FontWeight.w700,
),
),
),
TextButton.icon(
onPressed: () async {
if (isUrl) {
await _openUrl(context, Uri.parse(hint.command));
return;
}
await Clipboard.setData(ClipboardData(text: hint.command));
messenger.showSnackBar(
const SnackBar(content: Text('Install command copied')),
);
},
icon: Icon(
isUrl
? Icons.open_in_new_rounded
: Icons.content_copy_rounded,
),
label: Text(isUrl ? 'Open' : 'Copy'),
),
],
),
const SizedBox(height: 8),
SelectableText(
hint.command,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
fontFamily: 'monospace',
color: foregroundColor,
),
),
],
),
),
);
}
bool _isWebUrl(String value) {
final uri = Uri.tryParse(value);
return uri != null &&
(uri.scheme == 'http' || uri.scheme == 'https') &&
uri.hasAuthority;
}
Future<void> _openUrl(BuildContext context, Uri uri) async {
final messenger = ScaffoldMessenger.of(context);
final opened = await launchUrl(uri, mode: LaunchMode.externalApplication);
if (!opened && context.mounted) {
messenger.showSnackBar(
SnackBar(content: Text('Could not open ${uri.toString()}')),
);
}
}
}
class _SeverityBadge extends StatelessWidget {
const _SeverityBadge({required this.issue});
final StartupDependencyIssue issue;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final scheme = theme.colorScheme;
final foregroundColor = issue.isRequired
? scheme.onErrorContainer
: scheme.onSecondaryContainer;
return DecoratedBox(
decoration: BoxDecoration(
color: foregroundColor.withValues(alpha: 0.10),
borderRadius: BorderRadius.circular(999),
border: Border.all(color: foregroundColor.withValues(alpha: 0.18)),
),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
child: Text(
issue.isRequired ? 'Required' : 'Recommended',
style: theme.textTheme.labelMedium?.copyWith(
color: foregroundColor,
fontWeight: FontWeight.w600,
),
),
),
);
}
}
class _StartupCheckLoadingView extends StatelessWidget {
const _StartupCheckLoadingView();
@override
Widget build(BuildContext context) {
return const Scaffold(
body: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
CircularProgressIndicator(),
SizedBox(height: 16),
Text('Checking Linux runtime dependencies…'),
],
),
),
);
}
}
@@ -358,7 +358,6 @@ Future<void> showVoiceDetailsSheet(
required String pttBoundInputClass,
required bool isTouchOnly,
required rust.BridgeAudioProcessingConfig initialAudioConfig,
bool onnxRuntimeAvailable = true,
required ValueChanged<rust.BridgeTransmitMode> onModeChanged,
required ValueChanged<int> onReleaseTailChanged,
required ValueChanged<rust.BridgeAudioProcessingConfig> onAudioConfigChanged,
@@ -389,7 +388,6 @@ Future<void> showVoiceDetailsSheet(
pttBoundInputClass: pttBoundInputClass,
isTouchOnly: isTouchOnly,
initialAudioConfig: initialAudioConfig,
onnxRuntimeAvailable: onnxRuntimeAvailable,
onModeChanged: onModeChanged,
onReleaseTailChanged: onReleaseTailChanged,
onAudioConfigChanged: onAudioConfigChanged,
@@ -413,7 +411,6 @@ class _VoiceSheetBody extends StatefulWidget {
required this.pttBoundInputClass,
required this.isTouchOnly,
required this.initialAudioConfig,
required this.onnxRuntimeAvailable,
required this.onModeChanged,
required this.onReleaseTailChanged,
required this.onAudioConfigChanged,
@@ -431,7 +428,6 @@ class _VoiceSheetBody extends StatefulWidget {
final String pttBoundInputClass;
final bool isTouchOnly;
final rust.BridgeAudioProcessingConfig initialAudioConfig;
final bool onnxRuntimeAvailable;
final ValueChanged<rust.BridgeTransmitMode> onModeChanged;
final ValueChanged<int> onReleaseTailChanged;
final ValueChanged<rust.BridgeAudioProcessingConfig> onAudioConfigChanged;
@@ -466,10 +462,6 @@ class _VoiceSheetBodyState extends State<_VoiceSheetBody> {
_audioProcessing = AudioProcessingConfigState.fromConfig(
widget.initialAudioConfig,
);
_audioProcessing.vadBackend = normalizedVadBackend(
_audioProcessing.vadBackend,
onnxRuntimeAvailable: widget.onnxRuntimeAvailable,
);
// Poll audio stats at 250 ms so TX/RX counters and the level meter
// update in real time while the sheet is open, independent of the parent.
@@ -785,32 +777,15 @@ class _VoiceSheetBodyState extends State<_VoiceSheetBody> {
const SizedBox(height: 2),
SegmentedButton<rust.BridgeVadBackend>(
style: voiceSegmentedButtonStyle(theme),
segments: vadBackendSegmentsForAvailability(
desktop: _isDesktopSileroVadHost,
onnxRuntimeAvailable: widget.onnxRuntimeAvailable,
),
segments: _isDesktopSileroVadHost
? desktopVadBackendSegments
: vadBackendSegments,
selected: {_audioProcessing.vadBackend},
onSelectionChanged: (s) {
setState(
() => _audioProcessing.vadBackend = normalizedVadBackend(
s.first,
onnxRuntimeAvailable: widget.onnxRuntimeAvailable,
),
);
setState(() => _audioProcessing.vadBackend = s.first);
_notifyAudioConfig();
},
),
if (_isDesktopSileroVadHost) ...[
const SizedBox(height: 4),
Text(
widget.onnxRuntimeAvailable
? 'Silero needs ONNX Runtime. WebRTC works without it.'
: 'Silero is unavailable because ONNX Runtime was not found. WebRTC is selected.',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
],
// Debug.
const SizedBox(height: 8),
@@ -67,7 +67,6 @@ class VoiceSettingsDialog extends StatefulWidget {
this.pttLevel = '',
this.pttBackendId = '',
this.pttBoundInputClass = '',
this.onnxRuntimeAvailable = true,
this.talkPower,
this.neededTalkPower,
this.talkPowerGranted,
@@ -79,7 +78,6 @@ class VoiceSettingsDialog extends StatefulWidget {
final String pttLevel;
final String pttBackendId;
final String pttBoundInputClass;
final bool onnxRuntimeAvailable;
final int? talkPower;
final int? neededTalkPower;
final bool? talkPowerGranted;
@@ -103,10 +101,6 @@ class _VoiceSettingsDialogState extends State<VoiceSettingsDialog> {
_audioProcessing = AudioProcessingConfigState.fromConfig(
widget.initialAudioConfig,
);
_audioProcessing.vadBackend = normalizedVadBackend(
_audioProcessing.vadBackend,
onnxRuntimeAvailable: widget.onnxRuntimeAvailable,
);
}
rust.BridgeAudioProcessingConfig _buildConfig() {
@@ -297,29 +291,14 @@ class _VoiceSettingsDialogState extends State<VoiceSettingsDialog> {
const VoiceSubHeader('Backend'),
SegmentedButton<rust.BridgeVadBackend>(
style: voiceSegmentedButtonStyle(theme),
segments: vadBackendSegmentsForAvailability(
desktop: _isDesktopSileroVadHost,
onnxRuntimeAvailable: widget.onnxRuntimeAvailable,
),
segments: _isDesktopSileroVadHost
? desktopVadBackendSegments
: vadBackendSegments,
selected: {_audioProcessing.vadBackend},
onSelectionChanged: (s) => setState(
() => _audioProcessing.vadBackend = normalizedVadBackend(
s.first,
onnxRuntimeAvailable: widget.onnxRuntimeAvailable,
),
),
onSelectionChanged: (s) =>
setState(() => _audioProcessing.vadBackend = s.first),
),
const SizedBox(height: 8),
if (_isDesktopSileroVadHost)
Text(
widget.onnxRuntimeAvailable
? 'Silero gives the best quality when ONNX Runtime is installed. WebRTC works without ONNX Runtime and is the safer fallback if Linux setup is incomplete.'
: 'Silero is unavailable because ONNX Runtime was not found. WebRTC is selected until libonnxruntime.so is installed or bundled.',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
if (_isDesktopSileroVadHost) const SizedBox(height: 8),
// ── PTT capability badge ────────────────────────────────
if (_mode == rust.BridgeTransmitMode.ptt &&
@@ -59,30 +59,15 @@ const vadBackendSegments = [
/// Desktop VAD selector segments.
///
/// Desktop keeps Silero as the default, but WebRTC remains a supported
/// manual fallback when ONNX Runtime is missing or when the user wants a
/// smaller dependency surface.
const desktopVadBackendSegments = vadBackendSegments;
List<ButtonSegment<rust.BridgeVadBackend>> vadBackendSegmentsForAvailability({
required bool desktop,
required bool onnxRuntimeAvailable,
}) {
final segments = desktop ? desktopVadBackendSegments : vadBackendSegments;
if (!desktop || onnxRuntimeAvailable) return segments;
return [
for (final segment in segments)
if (segment.value == rust.BridgeVadBackend.sileroOnnx)
ButtonSegment<rust.BridgeVadBackend>(
value: segment.value,
label: segment.label,
icon: segment.icon,
enabled: false,
)
else
segment,
];
}
/// Windows and Linux use Silero as the primary VAD. WebRTC remains an
/// internal runtime fallback when the model/runtime is unavailable.
const desktopVadBackendSegments = [
ButtonSegment(
value: rust.BridgeVadBackend.sileroOnnx,
label: Text('Silero'),
icon: Icon(Icons.psychology, size: 14),
),
];
/// Section subheader used by both voice settings surfaces.
class VoiceSubHeader extends StatelessWidget {
-60
View File
@@ -9,21 +9,6 @@ set(BINARY_NAME "chanora_flutter")
# https://wiki.gnome.org/HowDoI/ChooseApplicationID
set(APPLICATION_ID "app.chanora.chanora_flutter")
# Repository root for Rust bridge builds / bundle staging.
set(CHANORA_REPO_ROOT "${CMAKE_CURRENT_SOURCE_DIR}/../../..")
# Optional path to a Linux ONNX Runtime shared library for Silero VAD.
# When set, the bundle carries a local `lib/libonnxruntime.so` sidecar and the
# runner exports `ORT_DYLIB_PATH` to that file before Flutter starts.
set(CHANORA_ONNXRUNTIME_SHARED_LIB "$ENV{CHANORA_ONNXRUNTIME_SHARED_LIB}" CACHE FILEPATH
"Absolute path to libonnxruntime.so for Linux bundle packaging")
# Optional path to a prebuilt Rust bridge shared library. When unset, the Linux
# bundle build runs `cargo build -p chanora_bridge` for the host profile and
# stages the resulting `libchanora_bridge.so` automatically.
set(CHANORA_BRIDGE_SHARED_LIB "$ENV{CHANORA_BRIDGE_SHARED_LIB}" CACHE FILEPATH
"Absolute path to libchanora_bridge.so for Linux bundle packaging")
# Explicitly opt in to modern CMake behaviors to avoid warnings with recent
# versions of CMake.
cmake_policy(SET CMP0063 NEW)
@@ -68,7 +53,6 @@ add_subdirectory(${FLUTTER_MANAGED_DIR})
# System-level dependencies.
find_package(PkgConfig REQUIRED)
pkg_check_modules(GTK REQUIRED IMPORTED_TARGET gtk+-3.0)
find_program(CARGO_EXECUTABLE cargo REQUIRED)
# Application build; see runner/CMakeLists.txt.
add_subdirectory("runner")
@@ -76,27 +60,6 @@ add_subdirectory("runner")
# Run the Flutter tool portions of the build. This must not be removed.
add_dependencies(${BINARY_NAME} flutter_assemble)
if(CMAKE_BUILD_TYPE MATCHES "Debug")
set(CHANORA_BRIDGE_PROFILE_DIR "debug")
set(CHANORA_BRIDGE_CARGO_ARGS build --package chanora_bridge)
else()
set(CHANORA_BRIDGE_PROFILE_DIR "release")
set(CHANORA_BRIDGE_CARGO_ARGS build --release --package chanora_bridge)
endif()
if(CHANORA_BRIDGE_SHARED_LIB)
set(CHANORA_BRIDGE_STAGED_LIB "${CHANORA_BRIDGE_SHARED_LIB}")
else()
set(CHANORA_BRIDGE_STAGED_LIB
"${CHANORA_REPO_ROOT}/target/${CHANORA_BRIDGE_PROFILE_DIR}/libchanora_bridge.so")
add_custom_target(chanora_bridge_bundle
COMMAND "${CARGO_EXECUTABLE}" ${CHANORA_BRIDGE_CARGO_ARGS}
WORKING_DIRECTORY "${CHANORA_REPO_ROOT}"
COMMENT "Building chanora_bridge for Linux bundle"
VERBATIM)
add_dependencies(${BINARY_NAME} chanora_bridge_bundle)
endif()
# Only the install-generated bundle's copy of the executable will launch
# correctly, since the resources must in the right relative locations. To avoid
# people trying to run the unbundled copy, put it in a subdirectory instead of
@@ -137,29 +100,6 @@ install(FILES "${FLUTTER_ICU_DATA_FILE}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}
install(FILES "${FLUTTER_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}"
COMPONENT Runtime)
if(EXISTS "${CHANORA_BRIDGE_STAGED_LIB}")
install(FILES "${CHANORA_BRIDGE_STAGED_LIB}"
DESTINATION "${INSTALL_BUNDLE_LIB_DIR}"
COMPONENT Runtime)
else()
message(WARNING
"Chanora bridge shared library was not found for Linux bundling: "
"${CHANORA_BRIDGE_STAGED_LIB}")
endif()
if(CHANORA_ONNXRUNTIME_SHARED_LIB)
if(EXISTS "${CHANORA_ONNXRUNTIME_SHARED_LIB}")
install(FILES "${CHANORA_ONNXRUNTIME_SHARED_LIB}"
DESTINATION "${INSTALL_BUNDLE_LIB_DIR}"
RENAME "libonnxruntime.so"
COMPONENT Runtime)
else()
message(WARNING
"CHANORA_ONNXRUNTIME_SHARED_LIB was set but the file was not found: "
"${CHANORA_ONNXRUNTIME_SHARED_LIB}")
endif()
endif()
foreach(bundled_library ${PLUGIN_BUNDLED_LIBRARIES})
install(FILES "${bundled_library}"
DESTINATION "${INSTALL_BUNDLE_LIB_DIR}"
@@ -14,27 +14,6 @@ struct _MyApplication {
G_DEFINE_TYPE(MyApplication, my_application, GTK_TYPE_APPLICATION)
static void configure_onnxruntime_dylib_path() {
const gchar* existing = g_getenv("ORT_DYLIB_PATH");
if (existing != nullptr && *existing != '\0') {
return;
}
g_autofree gchar* exe_path = g_file_read_link("/proc/self/exe", nullptr);
if (exe_path == nullptr) {
return;
}
g_autofree gchar* exe_dir = g_path_get_dirname(exe_path);
g_autofree gchar* ort_path =
g_build_filename(exe_dir, "lib", "libonnxruntime.so", nullptr);
if (!g_file_test(ort_path, G_FILE_TEST_IS_REGULAR)) {
return;
}
g_setenv("ORT_DYLIB_PATH", ort_path, TRUE);
}
// Called when first Flutter frame received.
static void first_frame_cb(MyApplication* self, FlView* view) {
gtk_widget_show(gtk_widget_get_toplevel(GTK_WIDGET(view)));
@@ -131,7 +110,6 @@ static void my_application_startup(GApplication* application) {
// MyApplication* self = MY_APPLICATION(object);
// Perform any actions required at application startup.
configure_onnxruntime_dylib_path();
G_APPLICATION_CLASS(my_application_parent_class)->startup(application);
}
@@ -21,6 +21,69 @@ void main() {
expect(ConnectionPhase.connected.canDisconnect, isTrue);
});
test('server-reachable phases show loading until snapshot is available', () {
expect(
ConnectionPhase.connecting.shouldShowSnapshotLoading(hasSnapshot: false),
isFalse,
);
expect(
ConnectionPhase.synchronizing.shouldShowSnapshotLoading(
hasSnapshot: false,
),
isTrue,
);
expect(
ConnectionPhase.connected.shouldShowSnapshotLoading(hasSnapshot: false),
isTrue,
);
expect(
ConnectionPhase.reconnecting.shouldShowSnapshotLoading(
hasSnapshot: false,
),
isTrue,
);
expect(
ConnectionPhase.synchronizing.shouldShowSnapshotLoading(
hasSnapshot: true,
),
isFalse,
);
});
test('chat only opens after a snapshot is available', () {
expect(
ConnectionPhase.synchronizing.canOpenChatWithSnapshot(hasSnapshot: false),
isFalse,
);
expect(
ConnectionPhase.synchronizing.canOpenChatWithSnapshot(hasSnapshot: true),
isTrue,
);
expect(
ConnectionPhase.connected.canOpenChatWithSnapshot(hasSnapshot: true),
isTrue,
);
expect(
ConnectionPhase.reconnecting.canOpenChatWithSnapshot(hasSnapshot: true),
isFalse,
);
});
test('snapshot application completes synchronizing phase', () {
expect(
phaseAfterSnapshotApplied(ConnectionPhase.synchronizing),
ConnectionPhase.connected,
);
expect(
phaseAfterSnapshotApplied(ConnectionPhase.connected),
ConnectionPhase.connected,
);
expect(
phaseAfterSnapshotApplied(ConnectionPhase.reconnecting),
ConnectionPhase.reconnecting,
);
});
test('connection status text maps all phases', () {
expect(
connectionStatusText(phase: ConnectionPhase.idle, l10n: l10n),
@@ -78,4 +141,22 @@ void main() {
expect(ConnectionPhase.reconnecting.tokens(scheme).icon, Icons.restart_alt);
expect(ConnectionPhase.disconnected.tokens(scheme).icon, Icons.cloud_off);
});
test('connected event preserves connected phase when snapshot exists', () {
expect(
phaseAfterConnectedEvent(ConnectionPhase.connected, hasSnapshot: true),
ConnectionPhase.connected,
);
});
test('connected event synchronizes when snapshot is missing', () {
expect(
phaseAfterConnectedEvent(ConnectionPhase.connected, hasSnapshot: false),
ConnectionPhase.synchronizing,
);
expect(
phaseAfterConnectedEvent(ConnectionPhase.reconnecting, hasSnapshot: true),
ConnectionPhase.synchronizing,
);
});
}
@@ -0,0 +1,48 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:chanora_flutter/services/prefetch_debouncer.dart';
void main() {
test('debounces host edits and prefetches latest trimmed host', () async {
final calls = <String>[];
final debouncer = PrefetchDebouncer(
delay: const Duration(milliseconds: 20),
onPrefetch: (host) async => calls.add(host),
);
debouncer.schedule(' first.example.com ');
debouncer.schedule(' second.example.com ');
await Future<void>.delayed(const Duration(milliseconds: 35));
expect(calls, ['second.example.com']);
debouncer.dispose();
});
test('skips empty hosts', () async {
final calls = <String>[];
final debouncer = PrefetchDebouncer(
delay: const Duration(milliseconds: 10),
onPrefetch: (host) async => calls.add(host),
);
debouncer.schedule(' ');
await Future<void>.delayed(const Duration(milliseconds: 25));
expect(calls, isEmpty);
debouncer.dispose();
});
test('dispose cancels pending prefetch', () async {
final calls = <String>[];
final debouncer = PrefetchDebouncer(
delay: const Duration(milliseconds: 30),
onPrefetch: (host) async => calls.add(host),
);
debouncer.schedule('example.com');
debouncer.dispose();
await Future<void>.delayed(const Duration(milliseconds: 45));
expect(calls, isEmpty);
});
}
@@ -18,7 +18,6 @@ void main() {
rust.BridgeClient client({
BigInt? id,
BigInt? channelId,
String uid = '',
int talkPower = 0,
bool talkPowerGranted = false,
bool inputMuted = false,
@@ -26,7 +25,6 @@ void main() {
}) {
return rust.BridgeClient(
id: id ?? BigInt.from(7),
uid: uid,
channel: channelId ?? BigInt.one,
name: 'Me',
inputMuted: inputMuted,
@@ -1,167 +0,0 @@
import 'dart:ffi';
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
import 'package:chanora_flutter/services/startup_dependency_check.dart';
void main() {
tearDown(() {
debugResetStartupDependencyCheck();
});
test('non-linux hosts skip startup dependency issues', () async {
debugResetStartupDependencyCheck(platformIsLinux: () => false);
final result = await checkStartupDependencies();
expect(result.issues, isEmpty);
});
test('missing Linux audio runtimes are reported as recommended', () async {
debugResetStartupDependencyCheck(
platformIsLinux: () => true,
libraryProbe: (candidate) => false,
fileExists: (_) async => false,
osReleaseProvider: () async => 'ID=ubuntu\nID_LIKE=debian\n',
resolvedExecutableProvider: () => '/opt/chanora/chanora_flutter',
currentDirectoryProvider: () => '/tmp',
);
final result = await checkStartupDependencies();
expect(result.hasIssues, isTrue);
final pipewire = result.issues.firstWhere(
(issue) => issue.id == 'linux-pipewire-runtime',
);
final pulse = result.issues.firstWhere(
(issue) => issue.id == 'linux-pulseaudio-runtime',
);
expect(pipewire.isRequired, isFalse);
expect(pulse.isRequired, isFalse);
expect(
pipewire.installHints.single.command,
'sudo apt install libpipewire-0.3-0',
);
expect(pulse.installHints.single.command, 'sudo apt install libpulse0');
});
test('distro detection parses quoted ID_LIKE lists', () async {
debugResetStartupDependencyCheck(
platformIsLinux: () => true,
libraryProbe: (candidate) => false,
fileExists: (_) async => true,
osReleaseProvider: () async => 'ID=rocky\nID_LIKE="fedora rhel"\n',
resolvedExecutableProvider: () => '/opt/chanora/chanora_flutter',
currentDirectoryProvider: () => '/tmp',
);
final result = await checkStartupDependencies();
expect(result.platformLabel, 'Fedora');
final pipewire = result.issues.singleWhere(
(issue) => issue.id == 'linux-pipewire-runtime',
);
expect(
pipewire.installHints.single.command,
'sudo dnf install pipewire-libs',
);
});
test(
'missing ONNX runtime is reported as recommended when Linux audio runtimes are present',
() async {
debugResetStartupDependencyCheck(
platformIsLinux: () => true,
libraryProbe: (candidate) =>
candidate.contains('pipewire') || candidate.contains('pulse'),
fileExists: (_) async => false,
osReleaseProvider: () async => 'ID=fedora\n',
resolvedExecutableProvider: () => '/opt/chanora/chanora_flutter',
currentDirectoryProvider: () => '/tmp',
currentAbiProvider: () => Abi.linuxX64,
);
final result = await checkStartupDependencies();
expect(result.issues, hasLength(1));
final ort = result.issues.single;
expect(ort.id, 'linux-onnxruntime');
expect(ort.isRequired, isFalse);
expect(
ort.installHints.any(
(hint) =>
hint.command ==
'https://github.com/microsoft/onnxruntime/releases',
),
isTrue,
);
expect(
ort.installHints.any(
(hint) => hint.command == 'onnxruntime-linux-x64-<version>.tgz',
),
isTrue,
);
expect(
ort.installHints.any((hint) => hint.command.contains('ORT_DYLIB_PATH')),
isTrue,
);
expect(
ort.details,
contains(
'This machine needs the Linux x64 CPU archive (onnxruntime-linux-x64-<version>.tgz).',
),
);
},
);
test('bundle-local ONNX runtime clears the recommendation', () async {
debugResetStartupDependencyCheck(
platformIsLinux: () => true,
libraryProbe: (candidate) =>
candidate.contains('pipewire') || candidate.contains('pulse'),
fileExists: (path) async => path == '/opt/chanora/lib/libonnxruntime.so',
resolvedExecutableProvider: () => '/opt/chanora/chanora_flutter',
currentDirectoryProvider: () => '/tmp',
);
final result = await checkStartupDependencies();
expect(result.issues, isEmpty);
});
test('logging missing startup dependencies appends a log line', () async {
final tempDir = await Directory.systemTemp.createTemp(
'chanora-startup-log',
);
addTearDown(() => tempDir.delete(recursive: true));
final logFile = File('${tempDir.path}/chanora.log');
debugResetStartupDependencyCheck(logFilePathProvider: () => logFile.path);
const result = StartupDependencyCheckResult(
platformLabel: 'Fedora',
issues: [
StartupDependencyIssue(
id: 'linux-pipewire-runtime',
title: 'PipeWire runtime is missing',
summary: 'Primary Linux audio needs PipeWire.',
details: ['Install PipeWire and recheck.'],
severity: StartupDependencySeverity.recommended,
),
],
);
await logStartupDependencyIssues(result);
final text = await logFile.readAsString();
expect(text, contains('[startup_dependency_check]'));
expect(text, contains('platform="Fedora"'));
expect(
text,
contains(
'issues=[linux-pipewire-runtime:recommended:PipeWire runtime is missing]',
),
);
});
}
@@ -16,6 +16,7 @@ void main() {
expect(settings.host, isEmpty);
expect(settings.nickname, isEmpty);
expect(settings.themeMode, UiThemeMode.system);
});
test('saves and loads host and nickname independently', () async {
@@ -40,78 +41,30 @@ void main() {
expect(await service.hasExplainedPermissions(), isTrue);
});
test('stores per-user playback preferences per server host', () async {
await service.saveClientPlaybackPreference(
serverHost: 'Example.COM',
userUid: 'user-a',
volume: 0.5,
muted: true,
);
await service.saveClientPlaybackPreference(
serverHost: 'other.example',
userUid: 'user-a',
volume: 2.0,
muted: false,
);
test('saves and loads theme mode independently', () async {
await service.saveSettings(host: 'example.com', nickname: 'Chanora');
await service.saveThemeMode(UiThemeMode.dark);
final examplePrefs = await service.loadClientPlaybackPreferencesForServer(
'example.com',
);
final otherPrefs = await service.loadClientPlaybackPreferencesForServer(
'other.example',
);
var settings = await service.loadSettings();
expect(examplePrefs['user-a']?.volume, 0.5);
expect(examplePrefs['user-a']?.muted, isTrue);
expect(otherPrefs['user-a']?.volume, 2.0);
expect(otherPrefs['user-a']?.muted, isFalse);
expect(settings.host, 'example.com');
expect(settings.nickname, 'Chanora');
expect(settings.themeMode, UiThemeMode.dark);
await service.saveThemeMode(UiThemeMode.light);
settings = await service.loadSettings();
expect(settings.host, 'example.com');
expect(settings.nickname, 'Chanora');
expect(settings.themeMode, UiThemeMode.light);
});
test(
'drops default playback preferences instead of persisting them',
() async {
await service.saveClientPlaybackPreference(
serverHost: 'example.com',
userUid: 'user-a',
volume: 0.2,
muted: true,
);
await service.saveClientPlaybackPreference(
serverHost: 'example.com',
userUid: 'user-a',
volume: 1.0,
muted: false,
);
test('falls back to system theme mode for invalid stored values', () async {
SharedPreferences.setMockInitialValues({'ui.theme_mode': 'sepia'});
service = const UiPreferencesService();
final prefs = await service.loadClientPlaybackPreferencesForServer(
'example.com',
);
expect(prefs, isEmpty);
},
);
final settings = await service.loadSettings();
test('ignores corrupt playback preference JSON', () async {
SharedPreferences.setMockInitialValues({
'audio.client_playback_prefs': '{not-json',
});
final prefs = await service.loadClientPlaybackPreferencesForServer(
'example.com',
);
expect(prefs, isEmpty);
});
test('clamps invalid playback volumes to unity', () async {
SharedPreferences.setMockInitialValues({
'audio.client_playback_prefs':
'{"example.com|user-a":{"volume":"invalid","muted":false}}',
});
final prefs = await service.loadClientPlaybackPreferencesForServer(
'example.com',
);
expect(prefs['user-a']?.volume, 1.0);
expect(settings.themeMode, UiThemeMode.system);
});
}
@@ -18,12 +18,44 @@ import 'package:flutter_test/flutter_test.dart';
// ignore_for_file: deprecated_member_use
import 'package:chanora_flutter/l10n/generated/app_localizations.dart';
import 'package:chanora_flutter/main.dart';
import 'package:chanora_flutter/services/android_permissions_service.dart';
import 'package:chanora_flutter/services/ios_permissions_service.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:chanora_flutter/widgets/permission_state_banner.dart';
import 'package:chanora_flutter/widgets/voice_compact.dart';
void main() {
testWidgets('theme menu emits selected mode from user action', (tester) async {
SharedPreferences.setMockInitialValues({});
var selected = ThemeMode.system;
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
appBar: AppBar(
actions: [
ChanoraThemeModeMenu(
themeMode: ThemeMode.system,
onThemeModeChanged: (mode) async {
selected = mode;
},
),
],
),
),
),
);
await tester.pumpAndSettle();
await tester.tap(find.byIcon(Icons.palette_outlined));
await tester.pumpAndSettle();
await tester.tap(find.text('Dark').last);
await tester.pumpAndSettle();
expect(selected, ThemeMode.dark);
});
testWidgets('renders English banner', (tester) async {
await tester.pumpWidget(
MaterialApp(
@@ -31,14 +31,14 @@ void main() {
expect(state.agcEnabled, isTrue);
});
test('desktop VAD normalization keeps explicit WebRTC selections', () {
test('normalizes desktop VAD backend to Silero', () {
expect(
normalizedVadBackend(
rust.BridgeVadBackend.webrtcVad,
isWindows: true,
isLinux: false,
),
rust.BridgeVadBackend.webrtcVad,
rust.BridgeVadBackend.sileroOnnx,
);
expect(
normalizedVadBackend(
@@ -46,33 +46,10 @@ void main() {
isWindows: false,
isLinux: true,
),
rust.BridgeVadBackend.webrtcVad,
);
expect(
normalizedVadBackend(
rust.BridgeVadBackend.energyDebug,
isWindows: true,
isLinux: false,
),
rust.BridgeVadBackend.sileroOnnx,
);
});
test(
'desktop VAD normalization falls back when ONNX Runtime is unavailable',
() {
expect(
normalizedVadBackend(
rust.BridgeVadBackend.sileroOnnx,
isWindows: false,
isLinux: true,
onnxRuntimeAvailable: false,
),
rust.BridgeVadBackend.webrtcVad,
);
},
);
test('default config matches the current platform fallback', () {
final fallback = defaultAudioProcessingConfig();
final desktop = Platform.isWindows || Platform.isLinux;
@@ -126,7 +103,6 @@ void main() {
test('builds Windows/Linux software WebRTC APM config consistently', () {
final state = AudioProcessingConfigState.fromConfig(baseConfig)
..vadBackend = rust.BridgeVadBackend.webrtcVad
..nsEnabled = true
..aecEnabled = false
..agcEnabled = true;
@@ -151,7 +127,7 @@ void main() {
for (final config in [windowsConfig, linuxConfig]) {
expect(config.processingBackend, rust.BridgeAudioBackend.webrtcApm);
expect(config.vadBackend, rust.BridgeVadBackend.webrtcVad);
expect(config.vadBackend, rust.BridgeVadBackend.sileroOnnx);
expect(config.aec, rust.BridgeEffectOwner.off);
expect(config.ns, rust.BridgeEffectOwner.webrtcApm);
expect(config.agc, rust.BridgeEffectOwner.webrtcApm);
@@ -36,12 +36,10 @@ void main() {
required BigInt id,
required String name,
required BigInt channelId,
String uid = '',
bool isServerQuery = false,
}) {
return rust.BridgeClient(
id: id,
uid: uid,
channel: channelId,
name: name,
inputMuted: false,
@@ -466,6 +464,8 @@ void main() {
) async {
await tester.pumpWidget(
MaterialApp(
localizationsDelegates: AppL10n.localizationsDelegates,
supportedLocales: AppL10n.supportedLocales,
home: ChatPage(
messages: [
entry(rust.BridgeMessageTarget.poke(BigInt.from(2))),
@@ -498,15 +498,7 @@ void main() {
final privateTop = tester.getTopLeft(find.text('Alpha').first).dy;
final serverTileSize = tester.getSize(
find
.ancestor(
of: find.byIcon(Icons.dns_outlined),
matching: find.byWidgetPredicate(
(widget) =>
widget is SizedBox &&
widget.width == 92 &&
widget.height == 92,
),
)
.ancestor(of: find.text('Server').first, matching: find.byType(Ink))
.first,
);
@@ -516,63 +508,23 @@ void main() {
expect(serverTileSize.height, 92);
});
testWidgets('chat sidebar uses MD3-style selected container colors', (
testWidgets('chat page uses a compact top rail on phone width', (
tester,
) async {
final theme = ThemeData(
useMaterial3: true,
colorScheme: ColorScheme.fromSeed(seedColor: Colors.teal),
);
await tester.pumpWidget(
MaterialApp(
theme: theme,
home: ChatPage(
messages: const [],
snapshot: snapshot(channels: const [], clients: const []),
initialTarget: const rust.BridgeMessageTarget.server(),
),
),
);
final serverIndicator = find.ancestor(
of: find.byIcon(Icons.dns_outlined),
matching: find.byType(AnimatedContainer),
);
final channelIndicator = find.ancestor(
of: find.byIcon(Icons.tag),
matching: find.byType(AnimatedContainer),
);
final serverDecoration =
tester.widget<AnimatedContainer>(serverIndicator).decoration
as BoxDecoration;
final channelDecoration =
tester.widget<AnimatedContainer>(channelIndicator).decoration
as BoxDecoration;
final serverLabel = tester.widget<Text>(find.text('Server').last);
final channelLabel = tester.widget<Text>(find.text('Channel'));
expect(tester.getSize(serverIndicator), const Size(76, 76));
expect(tester.getSize(channelIndicator), const Size(76, 76));
expect(serverDecoration.color, theme.colorScheme.primaryContainer);
expect(channelDecoration.color, Colors.transparent);
expect(serverLabel.style?.color, theme.colorScheme.onPrimaryContainer);
expect(channelLabel.style?.color, theme.colorScheme.onSurfaceVariant);
});
testWidgets('long private chat labels stay within fixed rail indicator', (
tester,
) async {
const longName = 'Very Long Private Chat Name That Should Not Stretch';
tester.view.physicalSize = const Size(390, 844);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
await tester.pumpWidget(
MaterialApp(
localizationsDelegates: AppL10n.localizationsDelegates,
supportedLocales: AppL10n.supportedLocales,
home: ChatPage(
messages: [
ChatEntry(
senderId: BigInt.from(2),
senderName: longName,
senderName: 'Alpha',
message: 'Private',
target: rust.BridgeMessageTarget.client(BigInt.from(2)),
),
@@ -580,29 +532,24 @@ void main() {
snapshot: snapshot(
channels: const [],
clients: [
client(
id: BigInt.from(2),
name: longName,
channelId: BigInt.zero,
),
client(id: BigInt.from(2), name: 'Alpha', channelId: BigInt.zero),
],
),
initialTarget: rust.BridgeMessageTarget.client(BigInt.from(2)),
initialClientName: longName,
),
),
);
final selectedIndicator = find.ancestor(
of: find.text(longName).first,
matching: find.byType(AnimatedContainer),
);
final privateLabel = tester.widget<Text>(find.text(longName).first);
await tester.pumpAndSettle();
expect(tester.getSize(selectedIndicator), const Size(76, 76));
expect(privateLabel.maxLines, 2);
expect(privateLabel.overflow, TextOverflow.ellipsis);
expect(privateLabel.textAlign, TextAlign.center);
expect(find.byType(VerticalDivider), findsNothing);
final serverTileSize = tester.getSize(
find
.ancestor(of: find.text('Server').first, matching: find.byType(Ink))
.first,
);
expect(serverTileSize.width, lessThan(92));
expect(serverTileSize.height, lessThan(92));
expect(tester.getTopLeft(find.text('Private').first).dy, greaterThan(92));
});
testWidgets(
@@ -610,6 +557,8 @@ void main() {
(tester) async {
await tester.pumpWidget(
MaterialApp(
localizationsDelegates: AppL10n.localizationsDelegates,
supportedLocales: AppL10n.supportedLocales,
home: ChatPage(
messages: [
ChatEntry(
@@ -715,6 +664,8 @@ void main() {
) async {
await tester.pumpWidget(
MaterialApp(
localizationsDelegates: AppL10n.localizationsDelegates,
supportedLocales: AppL10n.supportedLocales,
home: ChatPage(
messages: [
ChatEntry(
@@ -755,6 +706,8 @@ void main() {
await tester.pumpWidget(
MaterialApp(
localizationsDelegates: AppL10n.localizationsDelegates,
supportedLocales: AppL10n.supportedLocales,
home: ChatPage(
messages: messages,
snapshot: currentSnapshot,
@@ -822,6 +775,8 @@ void main() {
Ts3ServerLink? tapped;
await tester.pumpWidget(
MaterialApp(
localizationsDelegates: AppL10n.localizationsDelegates,
supportedLocales: AppL10n.supportedLocales,
home: ChatPage(
messages: [
ChatEntry(
@@ -0,0 +1,112 @@
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/client_info_sheet.dart';
void main() {
rust.BridgeClientProfile profile() {
return rust.BridgeClientProfile(
id: BigInt.from(101),
channel: BigInt.from(7),
name: 'Bob',
uniqueId: 'client-unique-id',
databaseId: BigInt.from(55),
countryCode: 'US',
description: 'Operator',
version: '3.6.2',
platform: 'Windows',
onlineSeconds: 3661,
idleMilliseconds: 42000,
pingMilliseconds: 38,
clientAddress: '203.0.113.24',
serverGroups: const ['Admin', 'Talk Power'],
channelGroup: 'Guest',
avatarPath: '/avatar_aabbcc',
bytesDownloadedMonth: BigInt.from(2048),
bytesUploadedMonth: BigInt.from(4096),
bytesDownloadedTotal: BigInt.from(1048576),
bytesUploadedTotal: BigInt.from(2097152),
packetLossClientToServerTotal: 0.0123,
packetLossServerToClientTotal: 0.0456,
);
}
testWidgets('renders loaded profile data in a modal sheet', (tester) async {
await tester.pumpWidget(
MaterialApp(
localizationsDelegates: AppL10n.localizationsDelegates,
supportedLocales: AppL10n.supportedLocales,
home: Builder(
builder: (context) => Scaffold(
body: Center(
child: FilledButton(
onPressed: () => showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
useSafeArea: true,
builder: (context) => FractionallySizedBox(
heightFactor: 0.86,
child: ClientInfoSheet(
clientName: 'Bob',
loadProfile: () async => profile(),
),
),
),
child: const Text('Open'),
),
),
),
),
),
);
await tester.tap(find.text('Open'));
await tester.pumpAndSettle();
expect(tester.takeException(), isNull);
expect(find.text('Bob'), findsOneWidget);
expect(find.text('Identity'), findsOneWidget);
expect(find.text('Membership'), findsOneWidget);
expect(find.text('Admin, Talk Power'), findsOneWidget);
await tester.drag(find.byType(ListView), const Offset(0, -360));
await tester.pumpAndSettle();
expect(find.text('Connection'), findsOneWidget);
expect(find.text('1h 1m 1s'), findsOneWidget);
expect(find.text('42.00 s'), findsOneWidget);
expect(find.text('203.0.113.24'), findsOneWidget);
});
testWidgets('loading and error states use localized copy', (tester) async {
await tester.pumpWidget(
MaterialApp(
locale: const Locale('zh'),
localizationsDelegates: AppL10n.localizationsDelegates,
supportedLocales: AppL10n.supportedLocales,
home: Scaffold(
body: SizedBox(
height: 500,
child: ClientInfoSheet(
clientName: 'Bob',
loadProfile: () => Future<rust.BridgeClientProfile>.error(
StateError('not available'),
),
),
),
),
),
);
expect(find.text('正在加载资料…'), findsOneWidget);
expect(find.text('Loading profile...'), findsNothing);
await tester.pumpAndSettle();
expect(find.text('资料不可用'), findsOneWidget);
expect(find.text('重试'), findsOneWidget);
expect(find.text('Profile unavailable'), findsNothing);
});
}
@@ -1,41 +0,0 @@
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/widgets/connect_widgets.dart';
void main() {
testWidgets('pressing enter in password field submits connection', (
tester,
) async {
var connectCount = 0;
final hostCtl = TextEditingController(text: 'example.com');
final nickCtl = TextEditingController(text: 'alice');
final passwordCtl = TextEditingController();
addTearDown(hostCtl.dispose);
addTearDown(nickCtl.dispose);
addTearDown(passwordCtl.dispose);
await tester.pumpWidget(
MaterialApp(
localizationsDelegates: AppL10n.localizationsDelegates,
supportedLocales: AppL10n.supportedLocales,
home: Scaffold(
body: ConnectForm(
hostCtl: hostCtl,
nickCtl: nickCtl,
passwordCtl: passwordCtl,
onConnect: () => connectCount += 1,
onAddBookmark: () {},
),
),
),
);
await tester.enterText(find.byType(TextField).last, 'secret');
await tester.testTextInput.receiveAction(TextInputAction.done);
await tester.pump();
expect(connectCount, 1);
});
}
@@ -1,41 +0,0 @@
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:chanora_flutter/widgets/input_dialogs.dart';
void main() {
test('browser back and forward logical keys map to mouse side bindings', () {
expect(
pttMouseSideButtonPlatformKeyForLogicalKey(
LogicalKeyboardKey.browserBack,
),
'mouse-side-button:8',
);
expect(
pttMouseSideButtonPlatformKeyForLogicalKey(LogicalKeyboardKey.goBack),
'mouse-side-button:8',
);
expect(
pttMouseSideButtonPlatformKeyForLogicalKey(
LogicalKeyboardKey.browserForward,
),
'mouse-side-button:16',
);
});
test('pointer button bitmasks map to mouse side bindings', () {
expect(
pttMouseSideButtonPlatformKeyForButtons(0x08),
'mouse-side-button:8',
);
expect(
pttMouseSideButtonPlatformKeyForButtons(0x10),
'mouse-side-button:16',
);
expect(
pttMouseSideButtonPlatformKeyForButtons(0x18),
'mouse-side-button:8',
);
expect(pttMouseSideButtonPlatformKeyForButtons(0x00), isNull);
});
}
@@ -0,0 +1,160 @@
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/main.dart';
import 'package:chanora_flutter/widgets/connect_widgets.dart';
import 'package:chanora_flutter/widgets/input_dialogs.dart';
import 'package:chanora_flutter/widgets/ptt_capability_badge.dart';
void main() {
Widget localizedHarness(Widget child, {Locale? locale}) {
return MaterialApp(
locale: locale,
localizationsDelegates: AppL10n.localizationsDelegates,
supportedLocales: AppL10n.supportedLocales,
home: Scaffold(body: child),
);
}
testWidgets('connect actions stack on narrow mobile widths', (tester) async {
await tester.pumpWidget(
localizedHarness(
SizedBox(
width: 320,
child: ConnectForm(
hostCtl: TextEditingController(text: 'server.example.com'),
nickCtl: TextEditingController(text: 'MobileUser'),
passwordCtl: TextEditingController(),
onConnect: () {},
onAddBookmark: () {},
),
),
),
);
await tester.pumpAndSettle();
final connectTop = tester.getTopLeft(find.text('Connect')).dy;
final bookmarkTop = tester.getTopLeft(find.text('Save bookmark')).dy;
expect(bookmarkTop, greaterThan(connectTop + 44));
});
testWidgets('connect actions stack at standard phone content widths', (
tester,
) async {
await tester.pumpWidget(
localizedHarness(
SizedBox(
width: 361,
child: ConnectForm(
hostCtl: TextEditingController(text: 'server.example.com'),
nickCtl: TextEditingController(text: 'MobileUser'),
passwordCtl: TextEditingController(),
onConnect: () {},
onAddBookmark: () {},
),
),
),
);
await tester.pumpAndSettle();
final connectTop = tester.getTopLeft(find.text('Connect')).dy;
final bookmarkTop = tester.getTopLeft(find.text('Save bookmark')).dy;
expect(bookmarkTop, greaterThan(connectTop + 44));
});
testWidgets('disconnected mobile chrome keeps content near the safe area', (
tester,
) async {
const primaryContentKey = Key('primary-content');
await tester.binding.setSurfaceSize(const Size(393, 852));
addTearDown(() => tester.binding.setSurfaceSize(null));
await tester.pumpWidget(
localizedHarness(
ChanoraMobileScaffold(
compactIdleChrome: true,
canDisconnect: false,
title: null,
actions: [
IconButton(
tooltip: 'About',
icon: const Icon(Icons.info_outline),
onPressed: () {},
),
],
onDisconnect: () {},
compactHeader: const SizedBox(height: 44, child: Text('Chanora')),
body: const KeyedSubtree(
key: primaryContentKey,
child: Text('Primary content'),
),
),
),
);
await tester.pumpAndSettle();
expect(find.byType(AppBar), findsNothing);
expect(tester.getTopLeft(find.byKey(primaryContentKey)).dy, lessThan(120));
});
testWidgets('PTT explanation sheet is scrollable for mobile text scaling', (
tester,
) async {
await tester.pumpWidget(
localizedHarness(
MediaQuery(
data: const MediaQueryData(textScaler: TextScaler.linear(1.8)),
child: const SizedBox(
width: 320,
child: PttCapabilityBadge(
level: 'L0Focused',
backendId: 'focused',
boundInputClass: 'keyboard',
),
),
),
),
);
await tester.tap(find.byIcon(Icons.info_outline));
await tester.pumpAndSettle();
expect(find.byType(SingleChildScrollView), findsWidgets);
expect(tester.takeException(), isNull);
});
testWidgets('PTT capture dialog wraps content for small screens', (
tester,
) async {
await tester.pumpWidget(
localizedHarness(
Builder(
builder: (context) {
return Center(
child: FilledButton(
onPressed: () => showDialog<void>(
context: context,
builder: (_) => const PttBindingCaptureDialog(),
),
child: const Text('Open'),
),
);
},
),
),
);
await tester.tap(find.text('Open'));
await tester.pumpAndSettle();
expect(find.byType(SingleChildScrollView), findsOneWidget);
expect(tester.takeException(), isNull);
});
}
@@ -1,9 +1,8 @@
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:chanora_flutter/l10n/generated/app_localizations.dart';
import 'package:chanora_flutter/services/ui_preferences_service.dart';
import 'package:chanora_flutter/src/rust/api.dart' as rust;
import 'package:chanora_flutter/widgets/snapshot_view.dart';
@@ -30,14 +29,12 @@ void main() {
required int id,
required int channelId,
required String name,
String uid = '',
bool speaking = false,
int talkPower = 0,
bool talkPowerGranted = false,
}) {
return rust.BridgeClient(
id: BigInt.from(id),
uid: uid,
channel: BigInt.from(channelId),
name: name,
inputMuted: false,
@@ -52,11 +49,14 @@ void main() {
Widget snapshotHarness({
required List<rust.BridgeChannel> channels,
required List<rust.BridgeClient> clients,
String welcomeMessage = '',
BigInt? ownClientId,
BigInt? currentVoiceChannelId,
rust.BridgeAudioStats? audioStats,
Map<String, ClientPlaybackPreference> clientPlaybackPreferences = const {},
ClientPlaybackPreferenceChanged? onClientPlaybackPreferenceChanged,
bool enableClientLongPressMenu = false,
ValueChanged<rust.BridgeClient>? onOpenClientInfo,
ValueChanged<rust.BridgeClient>? onOpenClientChat,
ValueChanged<rust.BridgeClient>? onOpenClientPoke,
}) {
return MaterialApp(
localizationsDelegates: AppL10n.localizationsDelegates,
@@ -65,7 +65,7 @@ void main() {
body: SnapshotView(
snapshot: rust.BridgeSnapshot(
serverName: 'Server',
welcomeMessage: '',
welcomeMessage: welcomeMessage,
platform: '',
version: '',
channels: channels,
@@ -81,8 +81,10 @@ void main() {
canJoinVoiceChannel: true,
onJoinChannel: (_) {},
onJoinChannelWithPassword: (_) {},
clientPlaybackPreferences: clientPlaybackPreferences,
onClientPlaybackPreferenceChanged: onClientPlaybackPreferenceChanged,
enableClientLongPressMenu: enableClientLongPressMenu,
onOpenClientInfo: onOpenClientInfo,
onOpenClientChat: onOpenClientChat,
onOpenClientPoke: onOpenClientPoke,
),
),
);
@@ -197,6 +199,27 @@ void main() {
expect(childUserX - childX, inInclusiveRange(22, 28));
});
testWidgets('server welcome starts collapsed above channel tree', (
tester,
) async {
await tester.pumpWidget(
snapshotHarness(
welcomeMessage:
'This is a long server welcome that should not push channels '
'below the first connected screen.',
channels: [channel(id: 1, name: 'Default Channel')],
clients: [client(id: 100, channelId: 1, name: 'Alice')],
),
);
await tester.pumpAndSettle();
expect(find.text('Server welcome message'), findsOneWidget);
expect(find.textContaining('long server welcome'), findsNothing);
expect(find.text('Default Channel'), findsOneWidget);
expect(find.text('Alice'), findsOneWidget);
});
testWidgets('password channel shows lock at row end', (tester) async {
await tester.pumpWidget(
snapshotHarness(
@@ -242,6 +265,219 @@ void main() {
expect(userHighlight.decoration, isNull);
});
testWidgets('long press opens client menu for other users', (tester) async {
rust.BridgeClient? chatClient;
rust.BridgeClient? pokeClient;
await tester.pumpWidget(
snapshotHarness(
channels: [channel(id: 1, name: 'Default Channel')],
clients: [
client(id: 100, channelId: 1, name: 'Alice'),
client(id: 101, channelId: 1, name: 'Bob'),
],
ownClientId: BigInt.from(100),
enableClientLongPressMenu: true,
onOpenClientChat: (client) => chatClient = client,
onOpenClientPoke: (client) => pokeClient = client,
),
);
await tester.pumpAndSettle();
await tester.longPress(find.text('Bob'));
await tester.pumpAndSettle();
expect(find.text('Private message'), findsOneWidget);
expect(find.text('Poke'), findsOneWidget);
await tester.tap(find.text('Private message'));
await tester.pumpAndSettle();
expect(chatClient?.id, BigInt.from(101));
expect(chatClient?.name, 'Bob');
expect(pokeClient, isNull);
});
testWidgets('long press is ignored when mobile client menus are disabled', (
tester,
) async {
await tester.pumpWidget(
snapshotHarness(
channels: [channel(id: 1, name: 'Default Channel')],
clients: [
client(id: 100, channelId: 1, name: 'Alice'),
client(id: 101, channelId: 1, name: 'Bob'),
],
ownClientId: BigInt.from(100),
enableClientLongPressMenu: false,
onOpenClientChat: (_) {},
onOpenClientPoke: (_) {},
),
);
await tester.pumpAndSettle();
await tester.longPress(find.text('Bob'));
await tester.pumpAndSettle();
expect(find.text('Private message'), findsNothing);
expect(find.text('Poke'), findsNothing);
});
testWidgets('secondary click opens client menu for other users', (
tester,
) async {
rust.BridgeClient? chatClient;
await tester.pumpWidget(
snapshotHarness(
channels: [channel(id: 1, name: 'Default Channel')],
clients: [
client(id: 100, channelId: 1, name: 'Alice'),
client(id: 101, channelId: 1, name: 'Bob'),
],
ownClientId: BigInt.from(100),
onOpenClientChat: (client) => chatClient = client,
),
);
await tester.pumpAndSettle();
await tester.tap(
find.text('Bob'),
buttons: kSecondaryMouseButton,
kind: PointerDeviceKind.mouse,
);
await tester.pumpAndSettle();
expect(find.text('Private message'), findsOneWidget);
await tester.tap(find.text('Private message'));
await tester.pumpAndSettle();
expect(chatClient?.id, BigInt.from(101));
});
testWidgets('client menu poke action targets selected other user', (
tester,
) async {
rust.BridgeClient? pokeClient;
await tester.pumpWidget(
snapshotHarness(
channels: [channel(id: 1, name: 'Default Channel')],
clients: [
client(id: 100, channelId: 1, name: 'Alice'),
client(id: 101, channelId: 1, name: 'Bob'),
],
ownClientId: BigInt.from(100),
enableClientLongPressMenu: true,
onOpenClientPoke: (client) => pokeClient = client,
),
);
await tester.pumpAndSettle();
await tester.longPress(find.text('Bob'));
await tester.pumpAndSettle();
await tester.tap(find.text('Poke'));
await tester.pumpAndSettle();
expect(pokeClient?.id, BigInt.from(101));
expect(pokeClient?.name, 'Bob');
expect(find.text('Poke'), findsNothing);
});
testWidgets('client menu info action targets selected other user', (
tester,
) async {
rust.BridgeClient? infoClient;
await tester.pumpWidget(
snapshotHarness(
channels: [channel(id: 1, name: 'Default Channel')],
clients: [
client(id: 100, channelId: 1, name: 'Alice'),
client(id: 101, channelId: 1, name: 'Bob'),
],
ownClientId: BigInt.from(100),
enableClientLongPressMenu: true,
onOpenClientInfo: (client) => infoClient = client,
),
);
await tester.pumpAndSettle();
await tester.longPress(find.text('Bob'));
await tester.pumpAndSettle();
expect(find.text('Info'), findsOneWidget);
await tester.tap(find.text('Info'));
await tester.pumpAndSettle();
expect(infoClient?.id, BigInt.from(101));
expect(infoClient?.name, 'Bob');
});
testWidgets('client menu info action is available for self', (tester) async {
rust.BridgeClient? infoClient;
rust.BridgeClient? chatClient;
rust.BridgeClient? pokeClient;
await tester.pumpWidget(
snapshotHarness(
channels: [channel(id: 1, name: 'Default Channel')],
clients: [
client(id: 100, channelId: 1, name: 'Alice'),
client(id: 101, channelId: 1, name: 'Bob'),
],
ownClientId: BigInt.from(100),
enableClientLongPressMenu: true,
onOpenClientInfo: (client) => infoClient = client,
onOpenClientChat: (client) => chatClient = client,
onOpenClientPoke: (client) => pokeClient = client,
),
);
await tester.pumpAndSettle();
await tester.longPress(find.text('Alice'));
await tester.pumpAndSettle();
expect(find.text('Info'), findsOneWidget);
expect(find.text('Private message'), findsNothing);
expect(find.text('Poke'), findsNothing);
await tester.tap(find.text('Info'));
await tester.pumpAndSettle();
expect(infoClient?.id, BigInt.from(100));
expect(infoClient?.name, 'Alice');
expect(chatClient, isNull);
expect(pokeClient, isNull);
});
testWidgets('self menu is hidden when only peer actions are available', (
tester,
) async {
await tester.pumpWidget(
snapshotHarness(
channels: [channel(id: 1, name: 'Default Channel')],
clients: [
client(id: 100, channelId: 1, name: 'Alice'),
client(id: 101, channelId: 1, name: 'Bob'),
],
ownClientId: BigInt.from(100),
enableClientLongPressMenu: true,
onOpenClientChat: (_) {},
onOpenClientPoke: (_) {},
),
);
await tester.pumpAndSettle();
await tester.longPress(find.text('Alice'));
await tester.pumpAndSettle();
expect(find.text('Private message'), findsNothing);
expect(find.text('Poke'), findsNothing);
});
testWidgets(
'local voice activity does not light speaking state when blocked',
(tester) async {
@@ -270,114 +506,6 @@ void main() {
},
);
testWidgets('remote client row does not show playback ellipsis', (
tester,
) async {
await tester.pumpWidget(
snapshotHarness(
channels: [channel(id: 1, name: 'Default Channel')],
clients: [
client(id: 100, channelId: 1, name: 'Self', uid: 'self'),
client(id: 101, channelId: 1, name: 'Bob', uid: 'user-b'),
],
ownClientId: BigInt.from(100),
clientPlaybackPreferences: const {
'user-b': ClientPlaybackPreference(volume: 0.5, muted: false),
},
onClientPlaybackPreferenceChanged: (_, _) async {},
),
);
await tester.pumpAndSettle();
expect(find.byIcon(Icons.more_horiz), findsNothing);
});
testWidgets('remote client long press opens playback menu', (tester) async {
await tester.pumpWidget(
snapshotHarness(
channels: [channel(id: 1, name: 'Default Channel')],
clients: [
client(id: 100, channelId: 1, name: 'Self', uid: 'self'),
client(id: 101, channelId: 1, name: 'Bob', uid: 'user-b'),
],
ownClientId: BigInt.from(100),
onClientPlaybackPreferenceChanged: (_, _) async {},
),
);
await tester.pumpAndSettle();
await tester.longPress(find.widgetWithText(ListTile, 'Bob'));
await tester.pumpAndSettle();
expect(find.text('Mute playback'), findsOneWidget);
expect(find.text('Volume 100%'), findsOneWidget);
});
testWidgets('remote client secondary click opens playback menu', (
tester,
) async {
await tester.pumpWidget(
snapshotHarness(
channels: [channel(id: 1, name: 'Default Channel')],
clients: [
client(id: 100, channelId: 1, name: 'Self', uid: 'self'),
client(id: 101, channelId: 1, name: 'Bob', uid: 'user-b'),
],
ownClientId: BigInt.from(100),
onClientPlaybackPreferenceChanged: (_, _) async {},
),
);
await tester.pumpAndSettle();
await tester.tap(
find.widgetWithText(ListTile, 'Bob'),
buttons: kSecondaryButton,
);
await tester.pumpAndSettle();
expect(find.text('Mute playback'), findsOneWidget);
expect(find.text('Volume 100%'), findsOneWidget);
});
testWidgets('client playback menu forwards mute and volume changes', (
tester,
) async {
final changes = <ClientPlaybackPreference>[];
await tester.pumpWidget(
snapshotHarness(
channels: [channel(id: 1, name: 'Default Channel')],
clients: [
client(id: 100, channelId: 1, name: 'Self', uid: 'self'),
client(id: 101, channelId: 1, name: 'Bob', uid: 'user-b'),
],
ownClientId: BigInt.from(100),
onClientPlaybackPreferenceChanged: (_, preference) async {
changes.add(preference);
},
),
);
await tester.pumpAndSettle();
await tester.longPress(find.widgetWithText(ListTile, 'Bob'));
await tester.pumpAndSettle();
await tester.tap(find.text('Mute playback'));
await tester.pumpAndSettle();
expect(changes, isNotEmpty);
expect(changes.last.muted, isTrue);
await tester.longPress(find.widgetWithText(ListTile, 'Bob'));
await tester.pumpAndSettle();
await tester.tap(find.text('Volume 200%'), warnIfMissed: false);
await tester.pumpAndSettle();
expect(changes.last.volume, 2.0);
expect(changes.last.muted, isFalse);
});
testWidgets('spacer channels render as layout rows and keep channel taps', (
tester,
) async {
@@ -1,67 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:chanora_flutter/services/startup_dependency_check.dart';
import 'package:chanora_flutter/widgets/startup_dependency_screen.dart';
void main() {
testWidgets('startup gate shows install-help screen and can continue', (
tester,
) async {
tester.view.physicalSize = const Size(1200, 1800);
tester.view.devicePixelRatio = 1.0;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
final result = StartupDependencyCheckResult(
platformLabel: 'Fedora',
issues: const [
StartupDependencyIssue(
id: 'linux-pipewire-runtime',
title: 'PipeWire runtime is missing',
summary: 'Primary Linux audio needs PipeWire.',
details: ['Install PipeWire and recheck.'],
severity: StartupDependencySeverity.recommended,
installHints: [
StartupInstallHint(
label: 'Release downloads',
command: 'https://github.com/microsoft/onnxruntime/releases',
),
StartupInstallHint(
label: 'Fedora',
command: 'sudo dnf install pipewire-libs',
),
],
),
],
);
final loggedResults = <StartupDependencyCheckResult>[];
await tester.pumpWidget(
MaterialApp(
home: StartupDependencyGate(
checker: () async => result,
logger: (value) async {
loggedResults.add(value);
},
child: const Scaffold(body: Text('ready')),
),
),
);
await tester.pumpAndSettle();
expect(find.text('Finish Linux setup'), findsOneWidget);
expect(find.text('PipeWire runtime is missing'), findsOneWidget);
expect(find.text('Continue anyway'), findsOneWidget);
expect(find.text('Open'), findsOneWidget);
expect(find.text('Copy'), findsOneWidget);
expect(loggedResults, [result]);
await tester.ensureVisible(find.text('Continue anyway'));
await tester.tap(find.text('Continue anyway'));
await tester.pumpAndSettle();
expect(find.text('ready'), findsOneWidget);
});
}
@@ -24,29 +24,12 @@ void main() {
]);
});
test('desktop VAD segments expose both Silero and WebRTC', () {
test('desktop VAD segments expose Silero as the primary backend', () {
expect(desktopVadBackendSegments.map((s) => s.value), [
rust.BridgeVadBackend.webrtcVad,
rust.BridgeVadBackend.sileroOnnx,
]);
});
test('desktop VAD segments disable Silero when ONNX Runtime is missing', () {
final segments = vadBackendSegmentsForAvailability(
desktop: true,
onnxRuntimeAvailable: false,
);
final silero = segments.singleWhere(
(segment) => segment.value == rust.BridgeVadBackend.sileroOnnx,
);
final webrtc = segments.singleWhere(
(segment) => segment.value == rust.BridgeVadBackend.webrtcVad,
);
expect(silero.enabled, isFalse);
expect(webrtc.enabled, isTrue);
});
testWidgets('shared segmented style applies compact visual density', (
tester,
) async {