Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
eca77ece81 | ||
|
|
3462de1eee | ||
|
|
f66118f5bb | ||
|
|
3ef540ae37 | ||
|
|
e0edcc89ac | ||
|
|
7922eabcf0 | ||
|
|
82bfa0ea1f | ||
|
|
dc52092654 | ||
|
|
b565663645 | ||
|
|
34a5247457 | ||
|
|
9a5f82565d | ||
|
|
409cd11c21 | ||
|
|
4f1b85cf76 | ||
|
|
b7cc4d2336 | ||
|
|
44f91a2ea4 | ||
|
|
cc6db18199 | ||
|
|
6af2ed9ab5 | ||
|
|
cf64274fe6 | ||
|
|
5c7a4b64c9 | ||
|
|
31cf45ce35 | ||
|
|
a644770488 | ||
|
|
5a1d902795 | ||
|
|
901369b072 | ||
|
|
57a4d9767b | ||
|
|
8c4f85ee70 | ||
|
|
7c341d42e5 | ||
|
|
8487acf167 | ||
|
|
8606eb48c8 | ||
|
|
d83539436e | ||
|
|
e4fdf8414a | ||
|
|
0f41993ed0 | ||
|
|
a0ff17b935 |
+16
-12
@@ -8,8 +8,10 @@ This project follows a Conventional Commits style workflow.
|
|||||||
|
|
||||||
The v0.3.0 milestone transitions Chanora from an internal-beta voice
|
The v0.3.0 milestone transitions Chanora from an internal-beta voice
|
||||||
prototype to a cross-platform baseline client with event-driven UI,
|
prototype to a cross-platform baseline client with event-driven UI,
|
||||||
per-user audio controls, non-self client info parity, and CI-hardened
|
visible per-client audio state, non-self client info parity, and
|
||||||
Android / iOS / macOS / Linux builds.
|
documented host Rust workspace plus Flutter validation gates. Android
|
||||||
|
target compile/install/smoke evidence remains blocked locally pending the
|
||||||
|
required NDK compiler and an authorized ADB target.
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
@@ -17,10 +19,9 @@ Android / iOS / macOS / Linux builds.
|
|||||||
deltas (client join/leave/move/update, channel add/remove/update)
|
deltas (client join/leave/move/update, channel add/remove/update)
|
||||||
flow through a typed `ProtocolDelta` enum and update the Flutter UI
|
flow through a typed `ProtocolDelta` enum and update the Flutter UI
|
||||||
in real time. Channel switching is instant.
|
in real time. Channel switching is instant.
|
||||||
- **Per-user volume controls.** Each client in the snapshot gets an
|
- **Per-client audio state visibility.** Client rows surface
|
||||||
independent volume slider persisted in the bridge layer. Avatar
|
muted/deafened state in avatar badges. Per-user volume UI, persistence,
|
||||||
badges show muted/deafened state. Volume adjustments take effect
|
and mixer wiring remain tracked as follow-up work.
|
||||||
immediately on the audio mix.
|
|
||||||
- **Non-self client info parity with Qint.** The Info tab now populates
|
- **Non-self client info parity with Qint.** The Info tab now populates
|
||||||
connection metadata (name, description, created, last connected,
|
connection metadata (name, description, created, last connected,
|
||||||
connections, transfer, ping deviation) for other clients via an
|
connections, transfer, ping deviation) for other clients via an
|
||||||
@@ -29,9 +30,10 @@ Android / iOS / macOS / Linux builds.
|
|||||||
- **Ping deviation in client profiles.** `ping_deviation_milliseconds`
|
- **Ping deviation in client profiles.** `ping_deviation_milliseconds`
|
||||||
propagated from protocol DTO through bridge API to Dart, with a
|
propagated from protocol DTO through bridge API to Dart, with a
|
||||||
conditional l10n row in the client info sheet (en + zh).
|
conditional l10n row in the client info sheet (en + zh).
|
||||||
- **Apple CoreML Silero VAD** as the preferred voice activity detector
|
- **Apple CoreML Silero VAD scaffolding/assets** for iOS / macOS when
|
||||||
on iOS / macOS when the private `silero-coreml` SwiftPM submodule is
|
the private `silero-coreml` SwiftPM package is available. Product
|
||||||
available. WebRTC VAD remains the runtime fallback.
|
`VoiceActivity` remains reserved/disabled per DEC-030 until a later
|
||||||
|
baseline enables and verifies it.
|
||||||
- **TeamSpeak address resolver** (`chanora_resolver`) for DNS SRV
|
- **TeamSpeak address resolver** (`chanora_resolver`) for DNS SRV
|
||||||
lookups and `ts3server://` URI handling.
|
lookups and `ts3server://` URI handling.
|
||||||
- **Per-ABI Android APK splitting.** `flutter build apk
|
- **Per-ABI Android APK splitting.** `flutter build apk
|
||||||
@@ -50,8 +52,9 @@ Android / iOS / macOS / Linux builds.
|
|||||||
- **iOS / macOS audio lifecycle hardened.** Voice unit restart-in-place,
|
- **iOS / macOS audio lifecycle hardened.** Voice unit restart-in-place,
|
||||||
serialized lifecycle events, WebRTC VAD on iOS, unblocked connect-time
|
serialized lifecycle events, WebRTC VAD on iOS, unblocked connect-time
|
||||||
audio startup.
|
audio startup.
|
||||||
- **Linux native audio path promoted** with ONNX Runtime bundled for
|
- **Linux native audio path promoted** with ONNX Runtime VAD assets
|
||||||
VAD. Desktop voice I/O works on PipeWire / PulseAudio.
|
bundled for future `VoiceActivity` work. Desktop voice I/O works on
|
||||||
|
PipeWire / PulseAudio; product `VoiceActivity` remains disabled.
|
||||||
- **Android audio routing** uses `MODE_IN_COMMUNICATION`, proper
|
- **Android audio routing** uses `MODE_IN_COMMUNICATION`, proper
|
||||||
startup permission flow, and system back-button integration.
|
startup permission flow, and system back-button integration.
|
||||||
- **`SnapshotChanged` event removed.** Replaced by the typed delta
|
- **`SnapshotChanged` event removed.** Replaced by the typed delta
|
||||||
@@ -59,7 +62,8 @@ Android / iOS / macOS / Linux builds.
|
|||||||
Flutter).
|
Flutter).
|
||||||
- **Prefetch crate renamed** from the PoC-era name to
|
- **Prefetch crate renamed** from the PoC-era name to
|
||||||
`chanora_prefetch`. All docs, specs, and code updated.
|
`chanora_prefetch`. All docs, specs, and code updated.
|
||||||
- **Build number bumped to 76.**
|
- **Flutter app version/build bumped to `0.3.0+100`.** Rust workspace
|
||||||
|
packages remain versioned separately at `0.2.0-beta.1`.
|
||||||
- **Flutter bridge regenerated** for `flutter_rust_bridge` 2.12.0.
|
- **Flutter bridge regenerated** for `flutter_rust_bridge` 2.12.0.
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|||||||
Generated
+1
-1
@@ -532,7 +532,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "chanora_resolver"
|
name = "chanora_resolver"
|
||||||
version = "0.1.0"
|
version = "0.2.0-beta.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"hickory-resolver",
|
"hickory-resolver",
|
||||||
|
|||||||
@@ -14,10 +14,10 @@ Flutter UI + Rust Core + tsclientlib
|
|||||||
|
|
||||||
## Status
|
## Status
|
||||||
|
|
||||||
Chanora is currently in early planning and baseline-candidate design.
|
Chanora is currently a baseline-candidate Flutter + Rust workspace. It is not production-ready and is not approved for public or store release.
|
||||||
|
|
||||||
```text
|
```text
|
||||||
Current documentation baseline: v0.9.2
|
Current documentation baseline: v0.9.x document set
|
||||||
Current status: Baseline Candidate
|
Current status: Baseline Candidate
|
||||||
Implementation status: Not production-ready
|
Implementation status: Not production-ready
|
||||||
```
|
```
|
||||||
@@ -25,7 +25,7 @@ Implementation status: Not production-ready
|
|||||||
The current engineering focus is:
|
The current engineering focus is:
|
||||||
|
|
||||||
- defining the system and software architecture;
|
- defining the system and software architecture;
|
||||||
- preparing the Flutter + Rust application structure;
|
- hardening the Flutter + Rust application structure;
|
||||||
- validating TeamSpeak-compatible protocol integration through `tsclientlib`;
|
- validating TeamSpeak-compatible protocol integration through `tsclientlib`;
|
||||||
- defining cross-platform audio behavior;
|
- defining cross-platform audio behavior;
|
||||||
- preparing release, verification, security, privacy, and legal gates.
|
- preparing release, verification, security, privacy, and legal gates.
|
||||||
@@ -49,7 +49,7 @@ Current platform policy:
|
|||||||
| iOS / iPadOS runtime target | iOS 16+ while Apple CoreML Silero VAD is linked |
|
| iOS / iPadOS runtime target | iOS 16+ while Apple CoreML Silero VAD is linked |
|
||||||
| macOS runtime target | macOS 13+ while Apple CoreML Silero VAD is linked |
|
| macOS runtime target | macOS 13+ while Apple CoreML Silero VAD is linked |
|
||||||
| App Store Connect upload gate | Xcode 26+ with iOS 26 / iPadOS 26 SDK+ for upload on or after 2026-04-28 |
|
| App Store Connect upload gate | Xcode 26+ with iOS 26 / iPadOS 26 SDK+ for upload on or after 2026-04-28 |
|
||||||
| Android runtime target | Android API 24+ unless Flutter, plugin, audio, or product constraints require raising it |
|
| Android runtime target | Android API 28+ per DEC-004, SysRS-288, SRS-187, and Gradle `minSdk = 28` |
|
||||||
| Google Play target API | Target the Google Play-required API level on upload date |
|
| Google Play target API | Target the Google Play-required API level on upload date |
|
||||||
|
|
||||||
The App Store / Play Store upload gates are release requirements. They are separate from local development and internal testing requirements.
|
The App Store / Play Store upload gates are release requirements. They are separate from local development and internal testing requirements.
|
||||||
@@ -230,7 +230,7 @@ docs/
|
|||||||
aspice-swe2-swe3-integration-note.md
|
aspice-swe2-swe3-integration-note.md
|
||||||
```
|
```
|
||||||
|
|
||||||
Implementation source folders may be added later. A likely structure is:
|
Implementation source folders are present in this workspace. The current high-level structure is:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
apps/
|
apps/
|
||||||
@@ -248,7 +248,7 @@ crates/
|
|||||||
chanora_bridge/
|
chanora_bridge/
|
||||||
```
|
```
|
||||||
|
|
||||||
The exact implementation layout should be finalized when the repository scaffold is created.
|
The exact implementation layout may continue to evolve as maintainability reviews split or merge Modules, but the repository scaffold exists.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -395,9 +395,7 @@ docs/governance/git-commit-message-convention.md
|
|||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|
||||||
Implementation commands will be added after the repository scaffold is finalized.
|
Common local commands include:
|
||||||
|
|
||||||
Expected future commands may include:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
flutter pub get
|
flutter pub get
|
||||||
@@ -407,7 +405,7 @@ cargo clippy
|
|||||||
cargo fmt
|
cargo fmt
|
||||||
```
|
```
|
||||||
|
|
||||||
Do not treat these as authoritative until the actual Flutter/Rust workspace has been created.
|
Android runtime success also requires an available Android NDK toolchain and an authorized device or emulator for build/install/smoke verification.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ android {
|
|||||||
ndkVersion = flutter.ndkVersion
|
ndkVersion = flutter.ndkVersion
|
||||||
|
|
||||||
compileOptions {
|
compileOptions {
|
||||||
|
isCoreLibraryDesugaringEnabled = true
|
||||||
sourceCompatibility = JavaVersion.VERSION_17
|
sourceCompatibility = JavaVersion.VERSION_17
|
||||||
targetCompatibility = JavaVersion.VERSION_17
|
targetCompatibility = JavaVersion.VERSION_17
|
||||||
}
|
}
|
||||||
@@ -198,6 +199,7 @@ android {
|
|||||||
// armeabi-v7a, x86_64, x86. AGP merges these into the APK/AAB.
|
// armeabi-v7a, x86_64, x86. AGP merges these into the APK/AAB.
|
||||||
dependencies {
|
dependencies {
|
||||||
implementation("com.microsoft.onnxruntime:onnxruntime-android:1.26.0")
|
implementation("com.microsoft.onnxruntime:onnxruntime-android:1.26.0")
|
||||||
|
coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.4")
|
||||||
}
|
}
|
||||||
|
|
||||||
flutter {
|
flutter {
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24">
|
||||||
|
<path
|
||||||
|
android:fillColor="@android:color/white"
|
||||||
|
android:pathData="M12,3C8.69,3 6,5.69 6,9V13C6,16.31 8.69,19 12,19C15.31,19 18,16.31 18,13V9C18,5.69 15.31,3 12,3ZM12,5C14.21,5 16,6.79 16,9V13C16,15.21 14.21,17 12,17C9.79,17 8,15.21 8,13V9C8,6.79 9.79,5 12,5ZM11,20V22H13V20H11Z" />
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources xmlns:tools="http://schemas.android.com/tools"
|
||||||
|
tools:keep="@drawable/ic_chanora_notification" />
|
||||||
@@ -6,6 +6,24 @@ import AVFoundation
|
|||||||
@objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate {
|
@objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate {
|
||||||
private var iosAudioLifecycleChannel: FlutterMethodChannel?
|
private var iosAudioLifecycleChannel: FlutterMethodChannel?
|
||||||
private var iosPlatformChannel: FlutterMethodChannel?
|
private var iosPlatformChannel: FlutterMethodChannel?
|
||||||
|
private var iosAudioSessionChannel: FlutterMethodChannel?
|
||||||
|
|
||||||
|
/// Tracks whether a voice channel is currently active.
|
||||||
|
///
|
||||||
|
/// The AVAudioSession is intentionally not configured for VoIP at
|
||||||
|
/// app launch — that would interrupt other apps' audio (Spotify,
|
||||||
|
/// Apple Music, podcasts) the moment the user opens Chanora, even
|
||||||
|
/// when they're just reading chat. Production VoIP apps (Telegram
|
||||||
|
/// group calls, Signal, Discord, Element) only switch the session
|
||||||
|
/// to `.playAndRecord` + `.voiceChat` when the user actually joins
|
||||||
|
/// a voice channel. See `docs/architecture/sad.md` and the
|
||||||
|
/// `chanora/ios_audio_session` MethodChannel contract.
|
||||||
|
///
|
||||||
|
/// This flag gates lifecycle handlers (interruption-ended,
|
||||||
|
/// media-services-reset) so we only rebuild the VoIP session if a
|
||||||
|
/// call is actually in progress. When false, those handlers leave
|
||||||
|
/// the session in the inactive `.ambient` baseline.
|
||||||
|
private var voiceSessionActive: Bool = false
|
||||||
|
|
||||||
override func application(
|
override func application(
|
||||||
_ application: UIApplication,
|
_ application: UIApplication,
|
||||||
@@ -15,91 +33,27 @@ import AVFoundation
|
|||||||
ChanoraSileroSelfTest.run()
|
ChanoraSileroSelfTest.run()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Configure the iOS AVAudioSession **category + mode** at
|
// AVAudioSession lifecycle policy (DEC-2026-06-08, supersedes
|
||||||
// app-launch time, but DEFER setActive(true) until the scene
|
// the launch-time .playAndRecord setup):
|
||||||
// is foregrounded. Calling setActive in didFinishLaunching is
|
|
||||||
// racy on iOS 17+ devices: if the user launches the app from a
|
|
||||||
// cold state, the UIApplication isn't yet `.active` and
|
|
||||||
// setActive returns `AVAudioSessionErrorCodeCannotStartPlaying`
|
|
||||||
// (561017449) — the iOS audio policy server refuses to grant
|
|
||||||
// the audio session because the app is not yet considered the
|
|
||||||
// foreground priority owner. Symptom in production builds:
|
|
||||||
// 'AVAudioSession setup failed: Error 561017449 "Session
|
|
||||||
// activation failed"' in NSLog, after which the audio engine
|
|
||||||
// is unusable until the user backgrounds + foregrounds the
|
|
||||||
// app.
|
|
||||||
//
|
//
|
||||||
// The category itself can be set whenever; only the active
|
// At launch we set the category to .ambient and leave the
|
||||||
// state needs to be deferred. We listen for
|
// session INACTIVE — matching the Telegram / Signal / Discord /
|
||||||
// didBecomeActiveNotification and activate then. Most
|
// Element / Jitsi pattern and Apple's guidance that "a VoIP
|
||||||
// production iOS voice apps (Discord, Zoom, FaceTime) follow
|
// app's audio session should not be active" while idle.
|
||||||
// this same shape.
|
// Configuring .playAndRecord + .voiceChat at launch stops other
|
||||||
|
// apps' music (Spotify, Apple Music, podcasts) the moment the
|
||||||
|
// user opens Chanora, even when they are just reading text chat.
|
||||||
|
//
|
||||||
|
// VoIP configuration is engaged on voice-channel join via the
|
||||||
|
// `chanora/ios_audio_session` MethodChannel, driven from Dart
|
||||||
|
// by the BridgeEvent::AudioStarted / AudioStopped lifecycle.
|
||||||
do {
|
do {
|
||||||
let session = AVAudioSession.sharedInstance()
|
try AVAudioSession.sharedInstance().setCategory(.ambient, mode: .default)
|
||||||
try session.setCategory(
|
logAudioSessionState(context: "launch-ambient")
|
||||||
.playAndRecord,
|
|
||||||
mode: .voiceChat,
|
|
||||||
// Mode rationale (May 2026, .voiceChat reinstated):
|
|
||||||
//
|
|
||||||
// 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.
|
|
||||||
//
|
|
||||||
// .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).
|
|
||||||
//
|
|
||||||
// .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
|
|
||||||
// * https://stackoverflow.com/questions/79834998 (Daily.co)
|
|
||||||
//
|
|
||||||
// Options:
|
|
||||||
// .defaultToSpeaker : route output to the main speaker
|
|
||||||
// (not the earpiece) by default
|
|
||||||
// when no headphones are connected.
|
|
||||||
// .allowBluetoothHFP : permit Bluetooth Hands-Free
|
|
||||||
// Profile headsets as both input
|
|
||||||
// and output.
|
|
||||||
// .allowBluetoothA2DP : permit higher-quality A2DP
|
|
||||||
// output-only Bluetooth devices.
|
|
||||||
options: [.defaultToSpeaker, .allowBluetoothHFP, .allowBluetoothA2DP]
|
|
||||||
)
|
|
||||||
// Match VPIO / Opus frame cadence to reduce callback pressure.
|
|
||||||
try session.setPreferredIOBufferDuration(0.02)
|
|
||||||
try session.setPreferredSampleRate(48000.0)
|
|
||||||
logAudioSessionState(context: "setCategory")
|
|
||||||
} catch {
|
} catch {
|
||||||
NSLog("chanora_flutter: AVAudioSession setCategory failed: \(error)")
|
NSLog("chanora_flutter: AVAudioSession .ambient baseline failed: \(error)")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Activate the session once the app is actually foreground. The
|
|
||||||
// notification fires immediately after the cold-launch settles,
|
|
||||||
// and again on every resume-from-background — both safe
|
|
||||||
// moments to call setActive(true). Repeated activation while
|
|
||||||
// already-active is a no-op per the docs.
|
|
||||||
NotificationCenter.default.addObserver(
|
|
||||||
self,
|
|
||||||
selector: #selector(activateAudioSession),
|
|
||||||
name: UIApplication.didBecomeActiveNotification,
|
|
||||||
object: nil
|
|
||||||
)
|
|
||||||
|
|
||||||
NotificationCenter.default.addObserver(
|
NotificationCenter.default.addObserver(
|
||||||
self,
|
self,
|
||||||
selector: #selector(handleRouteChange(_:)),
|
selector: #selector(handleRouteChange(_:)),
|
||||||
@@ -124,37 +78,60 @@ import AVFoundation
|
|||||||
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
|
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Called by `didBecomeActiveNotification` (cold-launch settle +
|
/// Activate the VoIP audio session. Called from Dart via the
|
||||||
/// every resume-from-background). Activates the AVAudioSession.
|
/// `chanora/ios_audio_session` channel when a voice channel join
|
||||||
/// Repeated activation is a no-op when the session is already
|
/// reaches the `BridgeEvent::AudioStarted` stage. Configures
|
||||||
/// active so this is safe to call on every foreground.
|
/// .playAndRecord + .voiceChat with .mixWithOthers so other apps
|
||||||
@objc private func activateAudioSession() {
|
/// (Spotify, podcasts) can keep playing alongside the voice
|
||||||
|
/// channel — matching the Telegram group-call UX. Idempotent:
|
||||||
|
/// repeated calls while already active are a no-op.
|
||||||
|
private func activateVoiceSession() {
|
||||||
do {
|
do {
|
||||||
try AVAudioSession.sharedInstance().setActive(true, options: [])
|
let session = AVAudioSession.sharedInstance()
|
||||||
NSLog("chanora_flutter: AVAudioSession activated on foreground")
|
try session.setCategory(
|
||||||
// Read back the ACTUAL session state. preferredSampleRate /
|
.playAndRecord,
|
||||||
// preferredIOBufferDuration are hints; iOS may pick something
|
mode: .voiceChat,
|
||||||
// else depending on hardware + currently-engaged effects.
|
options: [.defaultToSpeaker, .allowBluetoothHFP, .allowBluetoothA2DP, .mixWithOthers]
|
||||||
// Without these we can't tell whether VPIO is running at
|
)
|
||||||
// 48 kHz mono (what our render callback assumes) or at e.g.
|
try session.setPreferredIOBufferDuration(0.02)
|
||||||
// 44.1 kHz (which would explain the user's broken playback
|
try session.setPreferredSampleRate(48000.0)
|
||||||
// \u2014 our render callback would be writing samples at the
|
try session.setActive(true, options: [])
|
||||||
// wrong rate, causing pitch + timing artifacts).
|
voiceSessionActive = true
|
||||||
logAudioSessionState(context: "setActive")
|
logAudioSessionState(context: "activateVoiceSession")
|
||||||
let s = AVAudioSession.sharedInstance()
|
let ins = session.currentRoute.inputs.map { $0.portType.rawValue }.joined(separator: ",")
|
||||||
let ins = s.currentRoute.inputs.map { $0.portType.rawValue }.joined(separator: ",")
|
|
||||||
NSLog(
|
NSLog(
|
||||||
"chanora_flutter: AVAudioSession actual: " +
|
"chanora_flutter: voice session active: " +
|
||||||
"sampleRate=\(s.sampleRate) " +
|
"sampleRate=\(session.sampleRate) " +
|
||||||
"ioBufferDuration=\(String(format: "%.4f", s.ioBufferDuration)) " +
|
"ioBufferDuration=\(String(format: "%.4f", session.ioBufferDuration)) " +
|
||||||
"inputs=[\(ins)] " +
|
"inputs=[\(ins)] outputVolume=\(session.outputVolume)"
|
||||||
"outputVolume=\(s.outputVolume)"
|
|
||||||
)
|
)
|
||||||
} catch {
|
} catch {
|
||||||
NSLog("chanora_flutter: AVAudioSession setActive failed: \(error)")
|
NSLog("chanora_flutter: activateVoiceSession failed: \(error)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Deactivate the VoIP audio session and return to the idle
|
||||||
|
/// .ambient baseline. Called from Dart on `BridgeEvent::AudioStopped`
|
||||||
|
/// (intentional leave, disconnect, or connection lost).
|
||||||
|
/// `.notifyOthersOnDeactivation` lets other audio apps know they
|
||||||
|
/// can resume — best-effort: Apple Music / Podcasts resume
|
||||||
|
/// reliably, Spotify is not guaranteed.
|
||||||
|
private func deactivateVoiceSession() {
|
||||||
|
let session = AVAudioSession.sharedInstance()
|
||||||
|
do {
|
||||||
|
try session.setActive(false, options: [.notifyOthersOnDeactivation])
|
||||||
|
} catch {
|
||||||
|
NSLog("chanora_flutter: deactivateVoiceSession setActive(false) failed: \(error)")
|
||||||
|
}
|
||||||
|
do {
|
||||||
|
try session.setCategory(.ambient, mode: .default)
|
||||||
|
} catch {
|
||||||
|
NSLog("chanora_flutter: deactivateVoiceSession setCategory(.ambient) failed: \(error)")
|
||||||
|
}
|
||||||
|
voiceSessionActive = false
|
||||||
|
logAudioSessionState(context: "deactivateVoiceSession")
|
||||||
|
}
|
||||||
|
|
||||||
/// Reads back the actual AVAudioSession state and logs it for
|
/// Reads back the actual AVAudioSession state and logs it for
|
||||||
/// SDD-098 compliance. Called after both setCategory and setActive
|
/// SDD-098 compliance. Called after both setCategory and setActive
|
||||||
/// to verify that the session accepted the requested configuration.
|
/// to verify that the session accepted the requested configuration.
|
||||||
@@ -219,25 +196,30 @@ import AVFoundation
|
|||||||
}
|
}
|
||||||
|
|
||||||
@objc private func handleMediaServicesReset(_ notification: Notification) {
|
@objc private func handleMediaServicesReset(_ notification: Notification) {
|
||||||
NSLog("chanora_flutter: media services reset")
|
NSLog("chanora_flutter: media services reset voiceActive=\(voiceSessionActive)")
|
||||||
do {
|
if voiceSessionActive {
|
||||||
let session = AVAudioSession.sharedInstance()
|
do {
|
||||||
try session.setCategory(
|
let session = AVAudioSession.sharedInstance()
|
||||||
.playAndRecord,
|
try session.setCategory(
|
||||||
mode: .voiceChat,
|
.playAndRecord,
|
||||||
options: [.defaultToSpeaker, .allowBluetoothHFP, .allowBluetoothA2DP]
|
mode: .voiceChat,
|
||||||
)
|
options: [.defaultToSpeaker, .allowBluetoothHFP, .allowBluetoothA2DP, .mixWithOthers]
|
||||||
try session.setPreferredIOBufferDuration(0.02)
|
)
|
||||||
try session.setPreferredSampleRate(48000.0)
|
try session.setPreferredIOBufferDuration(0.02)
|
||||||
try session.setActive(true, options: [])
|
try session.setPreferredSampleRate(48000.0)
|
||||||
logAudioSessionState(context: "mediaServicesWereReset")
|
try session.setActive(true, options: [])
|
||||||
} catch {
|
logAudioSessionState(context: "mediaServicesWereReset-voip")
|
||||||
NSLog("chanora_flutter: AVAudioSession media-services reset rebuild failed: \(error)")
|
} catch {
|
||||||
|
NSLog("chanora_flutter: AVAudioSession media-services reset rebuild failed: \(error)")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
do {
|
||||||
|
try AVAudioSession.sharedInstance().setCategory(.ambient, mode: .default)
|
||||||
|
logAudioSessionState(context: "mediaServicesWereReset-ambient")
|
||||||
|
} catch {
|
||||||
|
NSLog("chanora_flutter: AVAudioSession media-services reset ambient restore failed: \(error)")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// P1: After rebuilding the session, send the current route class to
|
|
||||||
// Rust so it can recompute the processing policy and reset the
|
|
||||||
// AudioUnit. The Rust side handles this via ios_handle_media_services_reset
|
|
||||||
// which calls ios_restart_voice_unit.
|
|
||||||
let routeClass = classifyAudioRoute(AVAudioSession.sharedInstance().currentRoute)
|
let routeClass = classifyAudioRoute(AVAudioSession.sharedInstance().currentRoute)
|
||||||
NSLog("chanora_flutter: media services reset complete, route=\(routeClass)")
|
NSLog("chanora_flutter: media services reset complete, route=\(routeClass)")
|
||||||
iosAudioLifecycleChannel?.invokeMethod("handleMediaServicesReset", arguments: routeClass)
|
iosAudioLifecycleChannel?.invokeMethod("handleMediaServicesReset", arguments: routeClass)
|
||||||
@@ -269,6 +251,26 @@ import AVFoundation
|
|||||||
name: "chanora/ios_platform",
|
name: "chanora/ios_platform",
|
||||||
binaryMessenger: engineBridge.applicationRegistrar.messenger()
|
binaryMessenger: engineBridge.applicationRegistrar.messenger()
|
||||||
)
|
)
|
||||||
|
iosAudioSessionChannel = FlutterMethodChannel(
|
||||||
|
name: "chanora/ios_audio_session",
|
||||||
|
binaryMessenger: engineBridge.applicationRegistrar.messenger()
|
||||||
|
)
|
||||||
|
iosAudioSessionChannel?.setMethodCallHandler { [weak self] call, result in
|
||||||
|
guard let self = self else {
|
||||||
|
result(FlutterError(code: "delegate_gone", message: "AppDelegate deallocated", details: nil))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
switch call.method {
|
||||||
|
case "activateVoiceSession":
|
||||||
|
self.activateVoiceSession()
|
||||||
|
result(nil)
|
||||||
|
case "deactivateVoiceSession":
|
||||||
|
self.deactivateVoiceSession()
|
||||||
|
result(nil)
|
||||||
|
default:
|
||||||
|
result(FlutterMethodNotImplemented)
|
||||||
|
}
|
||||||
|
}
|
||||||
iosPlatformChannel?.setMethodCallHandler { call, result in
|
iosPlatformChannel?.setMethodCallHandler { call, result in
|
||||||
switch call.method {
|
switch call.method {
|
||||||
case "getMicrophonePermissionState":
|
case "getMicrophonePermissionState":
|
||||||
|
|||||||
@@ -25,7 +25,7 @@
|
|||||||
<key>CFBundleVersion</key>
|
<key>CFBundleVersion</key>
|
||||||
<string>$(FLUTTER_BUILD_NUMBER)</string>
|
<string>$(FLUTTER_BUILD_NUMBER)</string>
|
||||||
<key>ITSAppUsesNonExemptEncryption</key>
|
<key>ITSAppUsesNonExemptEncryption</key>
|
||||||
<false/>
|
<true/>
|
||||||
<key>LSRequiresIPhoneOS</key>
|
<key>LSRequiresIPhoneOS</key>
|
||||||
<true/>
|
<true/>
|
||||||
<key>LSSupportsOpeningDocumentsInPlace</key>
|
<key>LSSupportsOpeningDocumentsInPlace</key>
|
||||||
@@ -34,6 +34,8 @@
|
|||||||
<string>Chanora needs local network access to connect to your voice servers.</string>
|
<string>Chanora needs local network access to connect to your voice servers.</string>
|
||||||
<key>NSMicrophoneUsageDescription</key>
|
<key>NSMicrophoneUsageDescription</key>
|
||||||
<string>Chanora needs microphone access so you can talk on your voice server.</string>
|
<string>Chanora needs microphone access so you can talk on your voice server.</string>
|
||||||
|
<key>NSUserNotificationsUsageDescription</key>
|
||||||
|
<string>Chanora sends you a notification when another user pokes you.</string>
|
||||||
<key>UIApplicationSceneManifest</key>
|
<key>UIApplicationSceneManifest</key>
|
||||||
<dict>
|
<dict>
|
||||||
<key>UIApplicationSupportsMultipleScenes</key>
|
<key>UIApplicationSupportsMultipleScenes</key>
|
||||||
|
|||||||
@@ -242,19 +242,30 @@
|
|||||||
"clientInfoUnknown": "Unknown",
|
"clientInfoUnknown": "Unknown",
|
||||||
"clientInfoHidden": "Hidden",
|
"clientInfoHidden": "Hidden",
|
||||||
"clientInfoNone": "None",
|
"clientInfoNone": "None",
|
||||||
"pokeSnackBarClearAction": "Clear",
|
"pokeSettingsAction": "Poke notifications",
|
||||||
"pokeSnackBarMoreIndicator": "...",
|
"pokeSettingsTitle": "Poke notifications",
|
||||||
"pokeSnackBarIncomingNoMessage": "{sender} pokes you",
|
"pokeSettingsEnableLabel": "Notify me about pokes",
|
||||||
"@pokeSnackBarIncomingNoMessage": {
|
"pokeSettingsEnableDescription": "Show local notifications for incoming pokes when this is on.",
|
||||||
|
"pokeSettingsMutedSendersHeader": "Muted senders",
|
||||||
|
"pokeSettingsMutedSendersEmpty": "No muted poke senders.",
|
||||||
|
"pokeSettingsMutedSenderLabel": "Client ID {senderId}",
|
||||||
|
"@pokeSettingsMutedSenderLabel": {
|
||||||
|
"placeholders": {
|
||||||
|
"senderId": { "type": "String" }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"pokeSettingsUnmuteSenderAction": "Unmute",
|
||||||
|
"pokeOverflowMutePrompt": "Repeated pokes from {sender} were suppressed. Mute this sender?",
|
||||||
|
"@pokeOverflowMutePrompt": {
|
||||||
"placeholders": {
|
"placeholders": {
|
||||||
"sender": { "type": "String" }
|
"sender": { "type": "String" }
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"pokeSnackBarIncomingWithMessage": "{sender} pokes you: {message}",
|
"pokeOverflowMuteAction": "Mute",
|
||||||
"@pokeSnackBarIncomingWithMessage": {
|
"pokeMutedSenderConfirmation": "Muted pokes from {sender}",
|
||||||
|
"@pokeMutedSenderConfirmation": {
|
||||||
"placeholders": {
|
"placeholders": {
|
||||||
"sender": { "type": "String" },
|
"sender": { "type": "String" }
|
||||||
"message": { "type": "String" }
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"pokeHistorySelfNoMessage": "<{time}> You poked \"{target}\".",
|
"pokeHistorySelfNoMessage": "<{time}> You poked \"{target}\".",
|
||||||
|
|||||||
@@ -191,19 +191,30 @@
|
|||||||
"clientInfoUnknown": "未知",
|
"clientInfoUnknown": "未知",
|
||||||
"clientInfoHidden": "隐藏",
|
"clientInfoHidden": "隐藏",
|
||||||
"clientInfoNone": "无",
|
"clientInfoNone": "无",
|
||||||
"pokeSnackBarClearAction": "清除",
|
"pokeSettingsAction": "戳一戳通知",
|
||||||
"pokeSnackBarMoreIndicator": "...",
|
"pokeSettingsTitle": "戳一戳通知",
|
||||||
"pokeSnackBarIncomingNoMessage": "{sender} 戳了你一下",
|
"pokeSettingsEnableLabel": "接收戳一戳通知",
|
||||||
"@pokeSnackBarIncomingNoMessage": {
|
"pokeSettingsEnableDescription": "开启后,收到戳一戳时会显示本地通知。",
|
||||||
|
"pokeSettingsMutedSendersHeader": "已静音的发送者",
|
||||||
|
"pokeSettingsMutedSendersEmpty": "没有已静音的戳一戳发送者。",
|
||||||
|
"pokeSettingsMutedSenderLabel": "用户 ID {senderId}",
|
||||||
|
"@pokeSettingsMutedSenderLabel": {
|
||||||
|
"placeholders": {
|
||||||
|
"senderId": { "type": "String" }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"pokeSettingsUnmuteSenderAction": "取消静音",
|
||||||
|
"pokeOverflowMutePrompt": "来自 {sender} 的重复戳一戳已被抑制。要静音此发送者吗?",
|
||||||
|
"@pokeOverflowMutePrompt": {
|
||||||
"placeholders": {
|
"placeholders": {
|
||||||
"sender": { "type": "String" }
|
"sender": { "type": "String" }
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"pokeSnackBarIncomingWithMessage": "{sender} 戳了你一下:{message}",
|
"pokeOverflowMuteAction": "静音",
|
||||||
"@pokeSnackBarIncomingWithMessage": {
|
"pokeMutedSenderConfirmation": "已静音来自 {sender} 的戳一戳",
|
||||||
|
"@pokeMutedSenderConfirmation": {
|
||||||
"placeholders": {
|
"placeholders": {
|
||||||
"sender": { "type": "String" },
|
"sender": { "type": "String" }
|
||||||
"message": { "type": "String" }
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"pokeHistorySelfNoMessage": "<{time}> 你戳了“{target}”一下。",
|
"pokeHistorySelfNoMessage": "<{time}> 你戳了“{target}”一下。",
|
||||||
|
|||||||
@@ -1159,29 +1159,71 @@ abstract class AppL10n {
|
|||||||
/// **'None'**
|
/// **'None'**
|
||||||
String get clientInfoNone;
|
String get clientInfoNone;
|
||||||
|
|
||||||
/// No description provided for @pokeSnackBarClearAction.
|
/// No description provided for @pokeSettingsAction.
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
/// **'Clear'**
|
/// **'Poke notifications'**
|
||||||
String get pokeSnackBarClearAction;
|
String get pokeSettingsAction;
|
||||||
|
|
||||||
/// No description provided for @pokeSnackBarMoreIndicator.
|
/// No description provided for @pokeSettingsTitle.
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
/// **'...'**
|
/// **'Poke notifications'**
|
||||||
String get pokeSnackBarMoreIndicator;
|
String get pokeSettingsTitle;
|
||||||
|
|
||||||
/// No description provided for @pokeSnackBarIncomingNoMessage.
|
/// No description provided for @pokeSettingsEnableLabel.
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
/// **'{sender} pokes you'**
|
/// **'Notify me about pokes'**
|
||||||
String pokeSnackBarIncomingNoMessage(String sender);
|
String get pokeSettingsEnableLabel;
|
||||||
|
|
||||||
/// No description provided for @pokeSnackBarIncomingWithMessage.
|
/// No description provided for @pokeSettingsEnableDescription.
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
/// **'{sender} pokes you: {message}'**
|
/// **'Show local notifications for incoming pokes when this is on.'**
|
||||||
String pokeSnackBarIncomingWithMessage(String sender, String message);
|
String get pokeSettingsEnableDescription;
|
||||||
|
|
||||||
|
/// No description provided for @pokeSettingsMutedSendersHeader.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Muted senders'**
|
||||||
|
String get pokeSettingsMutedSendersHeader;
|
||||||
|
|
||||||
|
/// No description provided for @pokeSettingsMutedSendersEmpty.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'No muted poke senders.'**
|
||||||
|
String get pokeSettingsMutedSendersEmpty;
|
||||||
|
|
||||||
|
/// No description provided for @pokeSettingsMutedSenderLabel.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Client ID {senderId}'**
|
||||||
|
String pokeSettingsMutedSenderLabel(String senderId);
|
||||||
|
|
||||||
|
/// No description provided for @pokeSettingsUnmuteSenderAction.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Unmute'**
|
||||||
|
String get pokeSettingsUnmuteSenderAction;
|
||||||
|
|
||||||
|
/// No description provided for @pokeOverflowMutePrompt.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Repeated pokes from {sender} were suppressed. Mute this sender?'**
|
||||||
|
String pokeOverflowMutePrompt(String sender);
|
||||||
|
|
||||||
|
/// No description provided for @pokeOverflowMuteAction.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Mute'**
|
||||||
|
String get pokeOverflowMuteAction;
|
||||||
|
|
||||||
|
/// No description provided for @pokeMutedSenderConfirmation.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Muted pokes from {sender}'**
|
||||||
|
String pokeMutedSenderConfirmation(String sender);
|
||||||
|
|
||||||
/// No description provided for @pokeHistorySelfNoMessage.
|
/// No description provided for @pokeHistorySelfNoMessage.
|
||||||
///
|
///
|
||||||
|
|||||||
@@ -590,19 +590,43 @@ class AppL10nEn extends AppL10n {
|
|||||||
String get clientInfoNone => 'None';
|
String get clientInfoNone => 'None';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get pokeSnackBarClearAction => 'Clear';
|
String get pokeSettingsAction => 'Poke notifications';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get pokeSnackBarMoreIndicator => '...';
|
String get pokeSettingsTitle => 'Poke notifications';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String pokeSnackBarIncomingNoMessage(String sender) {
|
String get pokeSettingsEnableLabel => 'Notify me about pokes';
|
||||||
return '$sender pokes you';
|
|
||||||
|
@override
|
||||||
|
String get pokeSettingsEnableDescription =>
|
||||||
|
'Show local notifications for incoming pokes when this is on.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pokeSettingsMutedSendersHeader => 'Muted senders';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pokeSettingsMutedSendersEmpty => 'No muted poke senders.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String pokeSettingsMutedSenderLabel(String senderId) {
|
||||||
|
return 'Client ID $senderId';
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String pokeSnackBarIncomingWithMessage(String sender, String message) {
|
String get pokeSettingsUnmuteSenderAction => 'Unmute';
|
||||||
return '$sender pokes you: $message';
|
|
||||||
|
@override
|
||||||
|
String pokeOverflowMutePrompt(String sender) {
|
||||||
|
return 'Repeated pokes from $sender were suppressed. Mute this sender?';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pokeOverflowMuteAction => 'Mute';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String pokeMutedSenderConfirmation(String sender) {
|
||||||
|
return 'Muted pokes from $sender';
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|||||||
@@ -577,19 +577,42 @@ class AppL10nZh extends AppL10n {
|
|||||||
String get clientInfoNone => '无';
|
String get clientInfoNone => '无';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get pokeSnackBarClearAction => '清除';
|
String get pokeSettingsAction => '戳一戳通知';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get pokeSnackBarMoreIndicator => '...';
|
String get pokeSettingsTitle => '戳一戳通知';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String pokeSnackBarIncomingNoMessage(String sender) {
|
String get pokeSettingsEnableLabel => '接收戳一戳通知';
|
||||||
return '$sender 戳了你一下';
|
|
||||||
|
@override
|
||||||
|
String get pokeSettingsEnableDescription => '开启后,收到戳一戳时会显示本地通知。';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pokeSettingsMutedSendersHeader => '已静音的发送者';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pokeSettingsMutedSendersEmpty => '没有已静音的戳一戳发送者。';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String pokeSettingsMutedSenderLabel(String senderId) {
|
||||||
|
return '用户 ID $senderId';
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String pokeSnackBarIncomingWithMessage(String sender, String message) {
|
String get pokeSettingsUnmuteSenderAction => '取消静音';
|
||||||
return '$sender 戳了你一下:$message';
|
|
||||||
|
@override
|
||||||
|
String pokeOverflowMutePrompt(String sender) {
|
||||||
|
return '来自 $sender 的重复戳一戳已被抑制。要静音此发送者吗?';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pokeOverflowMuteAction => '静音';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String pokeMutedSenderConfirmation(String sender) {
|
||||||
|
return '已静音来自 $sender 的戳一戳';
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|||||||
+129
-170
@@ -22,10 +22,14 @@ import 'l10n/generated/app_localizations.dart';
|
|||||||
import 'services/android_permissions_service.dart';
|
import 'services/android_permissions_service.dart';
|
||||||
import 'services/app_bootstrap.dart';
|
import 'services/app_bootstrap.dart';
|
||||||
import 'services/audio_lifecycle_service.dart';
|
import 'services/audio_lifecycle_service.dart';
|
||||||
|
import 'services/ios_audio_session_controller.dart';
|
||||||
import 'services/channel_join_error_mapper.dart';
|
import 'services/channel_join_error_mapper.dart';
|
||||||
import 'services/connection_phase_state.dart';
|
import 'services/connection_phase_state.dart';
|
||||||
|
import 'services/hard_mute_owners.dart';
|
||||||
import 'services/ios_permissions_service.dart';
|
import 'services/ios_permissions_service.dart';
|
||||||
import 'services/macos_permissions_service.dart';
|
import 'services/macos_permissions_service.dart';
|
||||||
|
import 'services/poke_notification_service.dart';
|
||||||
|
import 'services/poke_preferences_service.dart';
|
||||||
import 'services/prefetch_debouncer.dart';
|
import 'services/prefetch_debouncer.dart';
|
||||||
import 'services/snapshot_state_mapper.dart';
|
import 'services/snapshot_state_mapper.dart';
|
||||||
import 'services/ts3_server_link.dart';
|
import 'services/ts3_server_link.dart';
|
||||||
@@ -41,6 +45,7 @@ import 'widgets/client_info_sheet.dart';
|
|||||||
import 'widgets/connect_widgets.dart';
|
import 'widgets/connect_widgets.dart';
|
||||||
import 'widgets/input_dialogs.dart';
|
import 'widgets/input_dialogs.dart';
|
||||||
import 'widgets/permission_state_banner.dart';
|
import 'widgets/permission_state_banner.dart';
|
||||||
|
import 'widgets/poke_notification_settings.dart';
|
||||||
import 'widgets/snapshot_view.dart';
|
import 'widgets/snapshot_view.dart';
|
||||||
import 'widgets/voice_platform.dart';
|
import 'widgets/voice_platform.dart';
|
||||||
import 'widgets/voice_bar.dart';
|
import 'widgets/voice_bar.dart';
|
||||||
@@ -159,10 +164,7 @@ class _ChanoraAppState extends State<ChanoraApp> {
|
|||||||
supportedLocales: AppL10n.supportedLocales,
|
supportedLocales: AppL10n.supportedLocales,
|
||||||
home: Stack(
|
home: Stack(
|
||||||
children: [
|
children: [
|
||||||
_BetaHome(
|
_BetaHome(themeMode: _themeMode, onThemeModeChanged: _setThemeMode),
|
||||||
themeMode: _themeMode,
|
|
||||||
onThemeModeChanged: _setThemeMode,
|
|
||||||
),
|
|
||||||
if (_showAudioDebugOverlay) const AudioDebugStatsPanel(),
|
if (_showAudioDebugOverlay) const AudioDebugStatsPanel(),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -200,6 +202,19 @@ extension on ThemeMode {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool isPokeSenderActiveChat({
|
||||||
|
required bool chatOpen,
|
||||||
|
required rust.BridgeMessageTarget? inlineChatTarget,
|
||||||
|
required BigInt senderId,
|
||||||
|
}) {
|
||||||
|
if (!chatOpen) return false;
|
||||||
|
return switch (inlineChatTarget) {
|
||||||
|
rust.BridgeMessageTarget_Poke(:final field0) ||
|
||||||
|
rust.BridgeMessageTarget_Client(:final field0) => field0 == senderId,
|
||||||
|
_ => false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
class ChanoraThemeModeMenu extends StatelessWidget {
|
class ChanoraThemeModeMenu extends StatelessWidget {
|
||||||
const ChanoraThemeModeMenu({
|
const ChanoraThemeModeMenu({
|
||||||
super.key,
|
super.key,
|
||||||
@@ -300,18 +315,6 @@ class _BetaHome extends StatefulWidget {
|
|||||||
State<_BetaHome> createState() => _BetaHomeState();
|
State<_BetaHome> createState() => _BetaHomeState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _ReceivedPoke {
|
|
||||||
const _ReceivedPoke({
|
|
||||||
required this.senderName,
|
|
||||||
required this.message,
|
|
||||||
required this.receivedAt,
|
|
||||||
});
|
|
||||||
|
|
||||||
final String senderName;
|
|
||||||
final String message;
|
|
||||||
final DateTime receivedAt;
|
|
||||||
}
|
|
||||||
|
|
||||||
class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
|
class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
|
||||||
final _hostCtl = TextEditingController(text: 'cn.teamspeak.app');
|
final _hostCtl = TextEditingController(text: 'cn.teamspeak.app');
|
||||||
final _nickCtl = TextEditingController(text: 'ChanoraBeta');
|
final _nickCtl = TextEditingController(text: 'ChanoraBeta');
|
||||||
@@ -338,8 +341,9 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
|
|||||||
bool _inChannel = false;
|
bool _inChannel = false;
|
||||||
rust.BridgeTransmitMode _transmitMode = rust.BridgeTransmitMode.ptt;
|
rust.BridgeTransmitMode _transmitMode = rust.BridgeTransmitMode.ptt;
|
||||||
bool _hardMute = false;
|
bool _hardMute = false;
|
||||||
bool _hardMuteByPermission = false;
|
HardMuteOwners _hardMuteOwners = const HardMuteOwners();
|
||||||
bool _hardMuteByTalkPower = false;
|
bool get _hardMuteByPermission => _hardMuteOwners.permission;
|
||||||
|
bool get _hardMuteByTalkPower => _hardMuteOwners.talkPower;
|
||||||
bool _permissionHardMuteClearInFlight = false;
|
bool _permissionHardMuteClearInFlight = false;
|
||||||
int _releaseTailMs = 200;
|
int _releaseTailMs = 200;
|
||||||
BigInt? _currentVoiceChannelId;
|
BigInt? _currentVoiceChannelId;
|
||||||
@@ -409,11 +413,6 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
|
|||||||
/// previous conversation when the user reopens chat.
|
/// previous conversation when the user reopens chat.
|
||||||
rust.BridgeMessageTarget? _lastDismissedTarget;
|
rust.BridgeMessageTarget? _lastDismissedTarget;
|
||||||
String _lastDismissedClientName = '';
|
String _lastDismissedClientName = '';
|
||||||
final ValueNotifier<List<_ReceivedPoke>> _pokeSnackBarPokes = ValueNotifier(
|
|
||||||
const [],
|
|
||||||
);
|
|
||||||
bool _pokeSnackBarVisible = false;
|
|
||||||
|
|
||||||
// SDD-106 / SRS-209: Android RECORD_AUDIO runtime permission service.
|
// SDD-106 / SRS-209: Android RECORD_AUDIO runtime permission service.
|
||||||
// Constructed at startup so cold-launch state is captured before the
|
// Constructed at startup so cold-launch state is captured before the
|
||||||
// first voice_join attempt. On non-Android hosts the service
|
// first voice_join attempt. On non-Android hosts the service
|
||||||
@@ -428,6 +427,9 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
|
|||||||
// MethodChannel.
|
// MethodChannel.
|
||||||
final MacOSPermissionsService _macOSPermissions = MacOSPermissionsService();
|
final MacOSPermissionsService _macOSPermissions = MacOSPermissionsService();
|
||||||
final UiPreferencesService _uiPreferences = const UiPreferencesService();
|
final UiPreferencesService _uiPreferences = const UiPreferencesService();
|
||||||
|
final PokeNotificationService _pokeNotifications = PokeNotificationService();
|
||||||
|
final PokePreferencesService _pokePreferences = PokePreferencesService();
|
||||||
|
late final Future<void> _pokePreferencesReady;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
@@ -461,6 +463,8 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
|
|||||||
_onMacOSPttCapabilityChanged,
|
_onMacOSPttCapabilityChanged,
|
||||||
);
|
);
|
||||||
_macOSPermissions.checkInitialStates();
|
_macOSPermissions.checkInitialStates();
|
||||||
|
unawaited(_pokeNotifications.init());
|
||||||
|
_pokePreferencesReady = _pokePreferences.load();
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
unawaited(_requestRecordAudioOnStartup());
|
unawaited(_requestRecordAudioOnStartup());
|
||||||
});
|
});
|
||||||
@@ -589,8 +593,8 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
|
|||||||
await rust.setHardMute(muted: false);
|
await rust.setHardMute(muted: false);
|
||||||
if (!mounted || !_hardMuteByPermission) return;
|
if (!mounted || !_hardMuteByPermission) return;
|
||||||
setState(() {
|
setState(() {
|
||||||
_hardMute = false;
|
_hardMuteOwners = _hardMuteOwners.copyWith(permission: false);
|
||||||
_hardMuteByPermission = false;
|
_hardMute = _hardMuteOwners.effective;
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
@@ -765,8 +769,10 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
|
|||||||
_resetConnectionUiState(phase: ConnectionPhase.disconnected);
|
_resetConnectionUiState(phase: ConnectionPhase.disconnected);
|
||||||
});
|
});
|
||||||
case rust.BridgeEvent_AudioStarted():
|
case rust.BridgeEvent_AudioStarted():
|
||||||
|
unawaited(iosAudioSessionController.activate());
|
||||||
_ensureStatsTimer();
|
_ensureStatsTimer();
|
||||||
case rust.BridgeEvent_AudioStopped():
|
case rust.BridgeEvent_AudioStopped():
|
||||||
|
unawaited(iosAudioSessionController.deactivate());
|
||||||
_statsTimer?.cancel();
|
_statsTimer?.cancel();
|
||||||
_statsTimer = null;
|
_statsTimer = null;
|
||||||
case rust.BridgeEvent_PttCapability(
|
case rust.BridgeEvent_PttCapability(
|
||||||
@@ -795,8 +801,8 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
|
|||||||
_voiceStateInitialized = true;
|
_voiceStateInitialized = true;
|
||||||
_inChannel = inChannel;
|
_inChannel = inChannel;
|
||||||
_transmitMode = transmitMode;
|
_transmitMode = transmitMode;
|
||||||
_hardMute = mute;
|
_hardMuteOwners = _hardMuteOwners.withBridgeManualMute(mute);
|
||||||
if (!mute) _hardMuteByPermission = false;
|
_hardMute = _hardMuteOwners.effective;
|
||||||
_releaseTailMs = releaseTailMs;
|
_releaseTailMs = releaseTailMs;
|
||||||
_currentVoiceChannelId = currentChannelId;
|
_currentVoiceChannelId = currentChannelId;
|
||||||
_pendingVoiceChannelId = pendingTargetChannelId;
|
_pendingVoiceChannelId = pendingTargetChannelId;
|
||||||
@@ -862,10 +868,11 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
|
|||||||
:final senderName,
|
:final senderName,
|
||||||
:final message,
|
:final message,
|
||||||
:final target,
|
:final target,
|
||||||
|
:final pokeStrength,
|
||||||
):
|
):
|
||||||
// Skip echo of self-sent messages (already added locally).
|
|
||||||
if (senderId == _snapshot?.ownClientId) return;
|
|
||||||
final isPoke = target is rust.BridgeMessageTarget_Poke;
|
final isPoke = target is rust.BridgeMessageTarget_Poke;
|
||||||
|
// Skip echo of self-sent non-poke messages (already added locally).
|
||||||
|
if (!isPoke && senderId == _snapshot?.ownClientId) return;
|
||||||
final receivedAt = DateTime.now();
|
final receivedAt = DateTime.now();
|
||||||
setState(() {
|
setState(() {
|
||||||
_appendChatEntryUnlocked(
|
_appendChatEntryUnlocked(
|
||||||
@@ -880,10 +887,13 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
if (isPoke) {
|
if (isPoke) {
|
||||||
_showPokeSnackBar(
|
unawaited(
|
||||||
senderName: senderName,
|
_handleIncomingPoke(
|
||||||
message: message,
|
senderId: senderId,
|
||||||
receivedAt: receivedAt,
|
senderName: senderName,
|
||||||
|
message: message,
|
||||||
|
strength: pokeStrength,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -1083,7 +1093,6 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
|
|||||||
_nickCtl.dispose();
|
_nickCtl.dispose();
|
||||||
_passwordCtl.dispose();
|
_passwordCtl.dispose();
|
||||||
_chatFeedRevision.dispose();
|
_chatFeedRevision.dispose();
|
||||||
_pokeSnackBarPokes.dispose();
|
|
||||||
_androidPermissions.recordAudioState.removeListener(
|
_androidPermissions.recordAudioState.removeListener(
|
||||||
_onRecordAudioPermissionChanged,
|
_onRecordAudioPermissionChanged,
|
||||||
);
|
);
|
||||||
@@ -1100,6 +1109,7 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
|
|||||||
_androidPermissions.stop();
|
_androidPermissions.stop();
|
||||||
_iosPermissions.stop();
|
_iosPermissions.stop();
|
||||||
_macOSPermissions.stop();
|
_macOSPermissions.stop();
|
||||||
|
_pokePreferences.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1133,7 +1143,9 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
|
|||||||
);
|
);
|
||||||
if (accessState == MacOSLocalNetworkState.denied) {
|
if (accessState == MacOSLocalNetworkState.denied) {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
setState(() { _phase = ConnectionPhase.idle; });
|
setState(() {
|
||||||
|
_phase = ConnectionPhase.idle;
|
||||||
|
});
|
||||||
_showLocalNetworkDeniedSnackBar();
|
_showLocalNetworkDeniedSnackBar();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -1268,8 +1280,8 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
|
|||||||
await rust.setHardMute(muted: true);
|
await rust.setHardMute(muted: true);
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_hardMute = true;
|
_hardMuteOwners = _hardMuteOwners.copyWith(permission: true);
|
||||||
_hardMuteByPermission = true;
|
_hardMute = _hardMuteOwners.effective;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
@@ -1284,8 +1296,8 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
|
|||||||
await rust.setHardMute(muted: false);
|
await rust.setHardMute(muted: false);
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_hardMute = false;
|
_hardMuteOwners = _hardMuteOwners.copyWith(permission: false);
|
||||||
_hardMuteByPermission = false;
|
_hardMute = _hardMuteOwners.effective;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1353,13 +1365,14 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
|
|||||||
final next = !_hardMute;
|
final next = !_hardMute;
|
||||||
final previousInputMuted = _inputMuted;
|
final previousInputMuted = _inputMuted;
|
||||||
final previousHardMute = _hardMute;
|
final previousHardMute = _hardMute;
|
||||||
final previousPermissionMute = _hardMuteByPermission;
|
final previousHardMuteOwners = _hardMuteOwners;
|
||||||
setState(() {
|
setState(() {
|
||||||
_inputMuted = next;
|
_inputMuted = next;
|
||||||
_hardMute = next;
|
_hardMuteOwners = _hardMuteOwners.copyWith(
|
||||||
if (next) {
|
manual: next,
|
||||||
_hardMuteByPermission = false;
|
permission: next ? false : null,
|
||||||
}
|
);
|
||||||
|
_hardMute = _hardMuteOwners.effective;
|
||||||
});
|
});
|
||||||
try {
|
try {
|
||||||
// Hard-mute is two coordinated effects:
|
// Hard-mute is two coordinated effects:
|
||||||
@@ -1378,7 +1391,7 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
|
|||||||
setState(() {
|
setState(() {
|
||||||
_inputMuted = previousInputMuted;
|
_inputMuted = previousInputMuted;
|
||||||
_hardMute = previousHardMute;
|
_hardMute = previousHardMute;
|
||||||
_hardMuteByPermission = previousPermissionMute;
|
_hardMuteOwners = previousHardMuteOwners;
|
||||||
});
|
});
|
||||||
_showUiError('hard mute', e);
|
_showUiError('hard mute', e);
|
||||||
}
|
}
|
||||||
@@ -1514,6 +1527,16 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _onOpenPokeSettings() async {
|
||||||
|
await _pokePreferences.load();
|
||||||
|
if (!mounted) return;
|
||||||
|
await showDialog<void>(
|
||||||
|
context: context,
|
||||||
|
builder: (ctx) =>
|
||||||
|
PokeNotificationSettingsDialog(preferences: _pokePreferences),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
Future<String?> _askChannelPassword(AppL10n l10n) async {
|
Future<String?> _askChannelPassword(AppL10n l10n) async {
|
||||||
// Same pattern as _onAddCurrentBookmark: route the dialog
|
// Same pattern as _onAddCurrentBookmark: route the dialog
|
||||||
// through a dedicated StatefulWidget so its
|
// through a dedicated StatefulWidget so its
|
||||||
@@ -1648,8 +1671,7 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
|
|||||||
_inputMuted = false;
|
_inputMuted = false;
|
||||||
_outputMuted = false;
|
_outputMuted = false;
|
||||||
_hardMute = false;
|
_hardMute = false;
|
||||||
_hardMuteByPermission = false;
|
_hardMuteOwners = const HardMuteOwners();
|
||||||
_hardMuteByTalkPower = false;
|
|
||||||
_inChannel = false;
|
_inChannel = false;
|
||||||
_currentVoiceChannelId = null;
|
_currentVoiceChannelId = null;
|
||||||
_pendingVoiceChannelId = null;
|
_pendingVoiceChannelId = null;
|
||||||
@@ -1799,9 +1821,7 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
|
|||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
if (!mounted || _inlineChatTarget == null) return;
|
if (!mounted || _inlineChatTarget == null) return;
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
SnackBar(
|
SnackBar(content: Text(AppL10n.of(context).chatPanelCollapsedHint)),
|
||||||
content: Text(AppL10n.of(context).chatPanelCollapsedHint),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -1822,52 +1842,68 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
void _showPokeSnackBar({
|
Future<void> _handleIncomingPoke({
|
||||||
|
required BigInt senderId,
|
||||||
required String senderName,
|
required String senderName,
|
||||||
required String message,
|
required String message,
|
||||||
required DateTime receivedAt,
|
required rust.BridgePokeStrength? strength,
|
||||||
}) {
|
}) async {
|
||||||
_pokeSnackBarPokes.value = [
|
if (senderId == _snapshot?.ownClientId) return;
|
||||||
..._pokeSnackBarPokes.value,
|
await _pokePreferencesReady;
|
||||||
_ReceivedPoke(
|
if (!_pokePreferences.pokesEnabled.value) return;
|
||||||
senderName: senderName,
|
if (_pokePreferences.isMuted(senderId)) return;
|
||||||
message: message,
|
final pokeStrength = strength ?? rust.BridgePokeStrength.suppressed;
|
||||||
receivedAt: receivedAt,
|
if (pokeStrength == rust.BridgePokeStrength.suppressedOverflow && mounted) {
|
||||||
),
|
_showPokeOverflowMutePrompt(senderId: senderId, senderName: senderName);
|
||||||
];
|
}
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
if (_isPokeSenderActiveChat(senderId)) return;
|
||||||
if (mounted) _renderPokeSnackBar();
|
await _pokeNotifications.show(
|
||||||
});
|
senderName: senderName,
|
||||||
|
message: message,
|
||||||
|
senderId: senderId,
|
||||||
|
strength: pokeStrength,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
void _renderPokeSnackBar() {
|
bool _isPokeSenderActiveChat(BigInt senderId) {
|
||||||
if (_pokeSnackBarPokes.value.isEmpty || _pokeSnackBarVisible) return;
|
return isPokeSenderActiveChat(
|
||||||
_pokeSnackBarVisible = true;
|
chatOpen: _chatOpen,
|
||||||
|
inlineChatTarget: _inlineChatTarget,
|
||||||
|
senderId: senderId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _showPokeOverflowMutePrompt({
|
||||||
|
required BigInt senderId,
|
||||||
|
required String senderName,
|
||||||
|
}) {
|
||||||
|
final l10n = AppL10n.of(context);
|
||||||
final messenger = ScaffoldMessenger.of(context);
|
final messenger = ScaffoldMessenger.of(context);
|
||||||
final controller = messenger.showSnackBar(
|
messenger.showSnackBar(
|
||||||
SnackBar(
|
SnackBar(
|
||||||
behavior: SnackBarBehavior.floating,
|
behavior: SnackBarBehavior.floating,
|
||||||
margin: _chatSnackBarMargin(),
|
margin: _chatSnackBarMargin(),
|
||||||
duration: const Duration(days: 365),
|
duration: const Duration(seconds: 8),
|
||||||
dismissDirection: DismissDirection.none,
|
content: Text(
|
||||||
content: _PokeSnackBarContent(pokes: _pokeSnackBarPokes),
|
l10n.pokeOverflowMutePrompt(senderName),
|
||||||
|
maxLines: 3,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
action: SnackBarAction(
|
action: SnackBarAction(
|
||||||
label: AppL10n.of(context).pokeSnackBarClearAction,
|
label: l10n.pokeOverflowMuteAction,
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
_pokeSnackBarPokes.value = const [];
|
unawaited(_pokePreferences.muteSender(senderId));
|
||||||
_pokeSnackBarVisible = false;
|
messenger.showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
behavior: SnackBarBehavior.floating,
|
||||||
|
margin: _chatSnackBarMargin(),
|
||||||
|
content: Text(l10n.pokeMutedSenderConfirmation(senderName)),
|
||||||
|
),
|
||||||
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
controller.closed.then((_) {
|
|
||||||
if (!mounted) return;
|
|
||||||
_pokeSnackBarVisible = false;
|
|
||||||
if (_pokeSnackBarPokes.value.isEmpty) return;
|
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
||||||
if (mounted) _renderPokeSnackBar();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void _showChatMessageSnackBar({
|
void _showChatMessageSnackBar({
|
||||||
@@ -1875,7 +1911,6 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
|
|||||||
required String message,
|
required String message,
|
||||||
required rust.BridgeMessageTarget target,
|
required rust.BridgeMessageTarget target,
|
||||||
}) {
|
}) {
|
||||||
if (_pokeSnackBarPokes.value.isNotEmpty) return;
|
|
||||||
final messenger = ScaffoldMessenger.of(context);
|
final messenger = ScaffoldMessenger.of(context);
|
||||||
messenger.hideCurrentSnackBar();
|
messenger.hideCurrentSnackBar();
|
||||||
messenger.showSnackBar(
|
messenger.showSnackBar(
|
||||||
@@ -2080,8 +2115,8 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
|
|||||||
|
|
||||||
if (!own.talkPowerOk && !_hardMuteByTalkPower) {
|
if (!own.talkPowerOk && !_hardMuteByTalkPower) {
|
||||||
final talkPowerEpoch = _connectionEpoch;
|
final talkPowerEpoch = _connectionEpoch;
|
||||||
_hardMuteByTalkPower = true;
|
_hardMuteOwners = _hardMuteOwners.copyWith(talkPower: true);
|
||||||
_hardMute = true;
|
_hardMute = _hardMuteOwners.effective;
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
if (!mounted ||
|
if (!mounted ||
|
||||||
_connectionEpoch != talkPowerEpoch ||
|
_connectionEpoch != talkPowerEpoch ||
|
||||||
@@ -2094,15 +2129,14 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
|
|||||||
});
|
});
|
||||||
} else if (own.talkPowerOk && _hardMuteByTalkPower) {
|
} else if (own.talkPowerOk && _hardMuteByTalkPower) {
|
||||||
final talkPowerEpoch = _connectionEpoch;
|
final talkPowerEpoch = _connectionEpoch;
|
||||||
_hardMuteByTalkPower = false;
|
_hardMuteOwners = _hardMuteOwners.copyWith(talkPower: false);
|
||||||
if (!_hardMuteByPermission) {
|
_hardMute = _hardMuteOwners.effective;
|
||||||
_hardMute = false;
|
if (!_hardMuteOwners.effective) {
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
if (!mounted ||
|
if (!mounted ||
|
||||||
_connectionEpoch != talkPowerEpoch ||
|
_connectionEpoch != talkPowerEpoch ||
|
||||||
!_serverReachable ||
|
!_serverReachable ||
|
||||||
_hardMuteByTalkPower ||
|
_hardMuteOwners.effective) {
|
||||||
_hardMuteByPermission) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
unawaited(rust.setHardMute(muted: false));
|
unawaited(rust.setHardMute(muted: false));
|
||||||
@@ -2373,6 +2407,11 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
|
|||||||
themeMode: widget.themeMode,
|
themeMode: widget.themeMode,
|
||||||
onThemeModeChanged: widget.onThemeModeChanged,
|
onThemeModeChanged: widget.onThemeModeChanged,
|
||||||
),
|
),
|
||||||
|
IconButton(
|
||||||
|
tooltip: l10n.pokeSettingsAction,
|
||||||
|
icon: const Icon(Icons.notifications_outlined),
|
||||||
|
onPressed: () => unawaited(_onOpenPokeSettings()),
|
||||||
|
),
|
||||||
if (_phase.canOpenChatWithSnapshot(hasSnapshot: _snapshot != null)) ...[
|
if (_phase.canOpenChatWithSnapshot(hasSnapshot: _snapshot != null)) ...[
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsetsDirectional.only(end: 12),
|
padding: const EdgeInsetsDirectional.only(end: 12),
|
||||||
@@ -2852,83 +2891,3 @@ class _LiveDiagnosticsDialogState extends State<_LiveDiagnosticsDialog> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _PokeSnackBarContent extends StatelessWidget {
|
|
||||||
const _PokeSnackBarContent({required this.pokes});
|
|
||||||
|
|
||||||
final ValueListenable<List<_ReceivedPoke>> pokes;
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return ValueListenableBuilder<List<_ReceivedPoke>>(
|
|
||||||
valueListenable: pokes,
|
|
||||||
builder: (context, entries, _) {
|
|
||||||
final l10n = AppL10n.of(context);
|
|
||||||
final visible = entries.length <= 3
|
|
||||||
? entries
|
|
||||||
: entries.sublist(entries.length - 3);
|
|
||||||
return Column(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
if (entries.length > 3)
|
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsetsDirectional.only(bottom: 2),
|
|
||||||
child: Text(
|
|
||||||
l10n.pokeSnackBarMoreIndicator,
|
|
||||||
style: const TextStyle(fontWeight: FontWeight.w600),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
for (final poke in visible) _PokeSnackBarRow(poke: poke),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class _PokeSnackBarRow extends StatelessWidget {
|
|
||||||
const _PokeSnackBarRow({required this.poke});
|
|
||||||
|
|
||||||
final _ReceivedPoke poke;
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
final l10n = AppL10n.of(context);
|
|
||||||
final message = poke.message.trim();
|
|
||||||
final text = message.isEmpty
|
|
||||||
? l10n.pokeSnackBarIncomingNoMessage(poke.senderName)
|
|
||||||
: l10n.pokeSnackBarIncomingWithMessage(poke.senderName, message);
|
|
||||||
|
|
||||||
return Padding(
|
|
||||||
padding: const EdgeInsets.symmetric(vertical: 1),
|
|
||||||
child: Row(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Expanded(
|
|
||||||
child: Text(
|
|
||||||
text,
|
|
||||||
maxLines: 1,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
style: const TextStyle(fontWeight: FontWeight.w600),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 12),
|
|
||||||
Text(
|
|
||||||
pokeSnackBarTimeLabel(poke.receivedAt),
|
|
||||||
style: TextStyle(
|
|
||||||
color: Theme.of(
|
|
||||||
context,
|
|
||||||
).colorScheme.onInverseSurface.withValues(alpha: 0.72),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
String pokeSnackBarTimeLabel(DateTime timestamp) {
|
|
||||||
String two(int value) => value.toString().padLeft(2, '0');
|
|
||||||
return '${two(timestamp.hour)}:${two(timestamp.minute)}';
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
class HardMuteOwners {
|
||||||
|
const HardMuteOwners({
|
||||||
|
this.manual = false,
|
||||||
|
this.permission = false,
|
||||||
|
this.talkPower = false,
|
||||||
|
});
|
||||||
|
|
||||||
|
final bool manual;
|
||||||
|
final bool permission;
|
||||||
|
final bool talkPower;
|
||||||
|
|
||||||
|
bool get effective => manual || permission || talkPower;
|
||||||
|
|
||||||
|
HardMuteOwners withBridgeManualMute(bool muted) {
|
||||||
|
return copyWith(
|
||||||
|
manual: muted && (manual || !permission && !talkPower),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
HardMuteOwners copyWith({
|
||||||
|
bool? manual,
|
||||||
|
bool? permission,
|
||||||
|
bool? talkPower,
|
||||||
|
}) {
|
||||||
|
return HardMuteOwners(
|
||||||
|
manual: manual ?? this.manual,
|
||||||
|
permission: permission ?? this.permission,
|
||||||
|
talkPower: talkPower ?? this.talkPower,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import 'dart:io' show Platform;
|
||||||
|
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
|
||||||
|
const iosAudioSessionChannelName = 'chanora/ios_audio_session';
|
||||||
|
|
||||||
|
/// Controls the iOS AVAudioSession VoIP lifecycle from Dart.
|
||||||
|
///
|
||||||
|
/// The Swift `AppDelegate` configures the session to `.ambient` at
|
||||||
|
/// launch and leaves it inactive. The session is only switched to
|
||||||
|
/// `.playAndRecord` + `.voiceChat` (with `.mixWithOthers`) while a
|
||||||
|
/// voice channel is actually active. This controller is the Dart
|
||||||
|
/// side of that contract — call [activate] when the Rust engine
|
||||||
|
/// emits `BridgeEvent::AudioStarted` and [deactivate] on
|
||||||
|
/// `BridgeEvent::AudioStopped`.
|
||||||
|
///
|
||||||
|
/// On non-iOS platforms both methods are no-ops; the platforms
|
||||||
|
/// handle their own session lifecycle elsewhere (Android via
|
||||||
|
/// `AndroidAudioLifecycleController`, macOS via
|
||||||
|
/// `MacOSAudioLifecycle`, desktop has no exclusive session).
|
||||||
|
class IosAudioSessionController {
|
||||||
|
IosAudioSessionController({
|
||||||
|
MethodChannel? channel,
|
||||||
|
bool? isIos,
|
||||||
|
}) : _channel = channel ?? const MethodChannel(iosAudioSessionChannelName),
|
||||||
|
_isIos = isIos ?? Platform.isIOS;
|
||||||
|
|
||||||
|
final MethodChannel _channel;
|
||||||
|
final bool _isIos;
|
||||||
|
|
||||||
|
Future<void> activate() async {
|
||||||
|
if (!_isIos) return;
|
||||||
|
try {
|
||||||
|
await _channel.invokeMethod<void>('activateVoiceSession');
|
||||||
|
} on PlatformException {
|
||||||
|
// Swift side logs the failure via NSLog; surfacing the
|
||||||
|
// exception to the event handler would be noise. The Rust
|
||||||
|
// engine remains alive and will produce silence until the
|
||||||
|
// next route change or a manual leave/rejoin.
|
||||||
|
} on MissingPluginException {
|
||||||
|
// Test hosts and mispackaged builds may not have registered
|
||||||
|
// the iOS channel. Keep event dispatch alive rather than
|
||||||
|
// surfacing an unhandled async error.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> deactivate() async {
|
||||||
|
if (!_isIos) return;
|
||||||
|
try {
|
||||||
|
await _channel.invokeMethod<void>('deactivateVoiceSession');
|
||||||
|
} on PlatformException {
|
||||||
|
// Same rationale as activate(): the Swift side logs.
|
||||||
|
// Worst case the session stays in .playAndRecord until the
|
||||||
|
// app is backgrounded — at which point iOS reclaims the
|
||||||
|
// session automatically.
|
||||||
|
} on MissingPluginException {
|
||||||
|
// Same rationale as activate(): missing channel should not
|
||||||
|
// break bridge event handling.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Default singleton used by [main.dart] event dispatch. Tests
|
||||||
|
/// should construct their own [IosAudioSessionController] with a
|
||||||
|
/// mocked channel rather than mutating this instance.
|
||||||
|
final iosAudioSessionController = IosAudioSessionController();
|
||||||
@@ -0,0 +1,179 @@
|
|||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
|
||||||
|
|
||||||
|
import '../src/rust/api.dart' as rust;
|
||||||
|
|
||||||
|
class PokeNotificationService {
|
||||||
|
PokeNotificationService({FlutterLocalNotificationsPlugin? notifications})
|
||||||
|
: _notifications = notifications ?? FlutterLocalNotificationsPlugin();
|
||||||
|
|
||||||
|
static const _strongAndroidChannelId = 'chanora_pokes_strong_v1';
|
||||||
|
static const _defaultAndroidChannelId = 'chanora_pokes_default_v1';
|
||||||
|
static const _groupKey = 'chanora.pokes';
|
||||||
|
static const _darwinThreadId = 'chanora.pokes';
|
||||||
|
static const _windowsHeader = WindowsHeader(
|
||||||
|
id: 'chanora.pokes',
|
||||||
|
title: 'Pokes',
|
||||||
|
arguments: 'pokes',
|
||||||
|
);
|
||||||
|
static const _windowsAppUserModelId = 'Chanora.Client';
|
||||||
|
static const _windowsGuid = '6B7F3DCB-4418-4E0A-8CC7-02B7C95B675E';
|
||||||
|
|
||||||
|
final FlutterLocalNotificationsPlugin _notifications;
|
||||||
|
bool _initialized = false;
|
||||||
|
|
||||||
|
Future<void> init() async {
|
||||||
|
if (_initialized) return;
|
||||||
|
await _notifications.initialize(
|
||||||
|
settings: const InitializationSettings(
|
||||||
|
android: AndroidInitializationSettings('ic_chanora_notification'),
|
||||||
|
iOS: DarwinInitializationSettings(
|
||||||
|
requestAlertPermission: false,
|
||||||
|
requestBadgePermission: false,
|
||||||
|
// TODO(event-sounds): handled by future EventSoundService, not the OS channel.
|
||||||
|
requestSoundPermission: false,
|
||||||
|
// TODO(event-sounds): handled by future EventSoundService, not the OS channel.
|
||||||
|
defaultPresentSound: false,
|
||||||
|
),
|
||||||
|
macOS: DarwinInitializationSettings(
|
||||||
|
requestAlertPermission: false,
|
||||||
|
requestBadgePermission: false,
|
||||||
|
// TODO(event-sounds): handled by future EventSoundService, not the OS channel.
|
||||||
|
requestSoundPermission: false,
|
||||||
|
// TODO(event-sounds): handled by future EventSoundService, not the OS channel.
|
||||||
|
defaultPresentSound: false,
|
||||||
|
),
|
||||||
|
linux: LinuxInitializationSettings(
|
||||||
|
defaultActionName: 'Open',
|
||||||
|
// TODO(event-sounds): handled by future EventSoundService, not the OS channel.
|
||||||
|
defaultSuppressSound: true,
|
||||||
|
),
|
||||||
|
windows: WindowsInitializationSettings(
|
||||||
|
appName: 'Chanora',
|
||||||
|
appUserModelId: _windowsAppUserModelId,
|
||||||
|
guid: _windowsGuid,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
_initialized = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<bool> requestPermission() async {
|
||||||
|
await init();
|
||||||
|
if (kIsWeb) return true;
|
||||||
|
|
||||||
|
final android = _notifications
|
||||||
|
.resolvePlatformSpecificImplementation<
|
||||||
|
AndroidFlutterLocalNotificationsPlugin
|
||||||
|
>();
|
||||||
|
if (android != null) {
|
||||||
|
return await android.requestNotificationsPermission() ?? true;
|
||||||
|
}
|
||||||
|
|
||||||
|
final ios = _notifications
|
||||||
|
.resolvePlatformSpecificImplementation<
|
||||||
|
IOSFlutterLocalNotificationsPlugin
|
||||||
|
>();
|
||||||
|
if (ios != null) {
|
||||||
|
return await ios.requestPermissions(alert: true, badge: true) ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
|
final macOS = _notifications
|
||||||
|
.resolvePlatformSpecificImplementation<
|
||||||
|
MacOSFlutterLocalNotificationsPlugin
|
||||||
|
>();
|
||||||
|
if (macOS != null) {
|
||||||
|
return await macOS.requestPermissions(alert: true, badge: true) ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> show({
|
||||||
|
required String senderName,
|
||||||
|
required String message,
|
||||||
|
required BigInt senderId,
|
||||||
|
required rust.BridgePokeStrength strength,
|
||||||
|
}) async {
|
||||||
|
await init();
|
||||||
|
final permitted = await requestPermission();
|
||||||
|
if (!permitted) return;
|
||||||
|
|
||||||
|
final trimmedMessage = message.trim();
|
||||||
|
final body = trimmedMessage.isEmpty
|
||||||
|
? '$senderName pokes you'
|
||||||
|
: trimmedMessage;
|
||||||
|
|
||||||
|
await _notifications.show(
|
||||||
|
id: senderId.toUnsigned(31).toInt(),
|
||||||
|
title: 'Poke from $senderName',
|
||||||
|
body: body,
|
||||||
|
notificationDetails: NotificationDetails(
|
||||||
|
android: _androidDetails(strength),
|
||||||
|
iOS: _darwinDetails(strength),
|
||||||
|
macOS: _darwinDetails(strength),
|
||||||
|
linux: _linuxDetails(strength),
|
||||||
|
windows: _windowsDetails(strength),
|
||||||
|
),
|
||||||
|
payload: 'poke:$senderId',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
AndroidNotificationDetails _androidDetails(rust.BridgePokeStrength strength) {
|
||||||
|
final isStrong = strength == rust.BridgePokeStrength.strong;
|
||||||
|
return AndroidNotificationDetails(
|
||||||
|
isStrong ? _strongAndroidChannelId : _defaultAndroidChannelId,
|
||||||
|
isStrong ? 'Pokes' : 'Pokes (quiet)',
|
||||||
|
channelDescription: 'TeamSpeak poke notifications',
|
||||||
|
importance: isStrong ? Importance.max : Importance.defaultImportance,
|
||||||
|
priority: isStrong ? Priority.high : Priority.defaultPriority,
|
||||||
|
// TODO(event-sounds): handled by future EventSoundService, not the OS channel.
|
||||||
|
playSound: false,
|
||||||
|
// TODO(event-sounds): handled by future EventSoundService, not the OS channel.
|
||||||
|
silent: true,
|
||||||
|
groupKey: _groupKey,
|
||||||
|
category: AndroidNotificationCategory.message,
|
||||||
|
visibility: NotificationVisibility.private,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
DarwinNotificationDetails _darwinDetails(rust.BridgePokeStrength strength) {
|
||||||
|
return DarwinNotificationDetails(
|
||||||
|
// TODO(event-sounds): handled by future EventSoundService, not the OS channel.
|
||||||
|
presentSound: false,
|
||||||
|
threadIdentifier: _darwinThreadId,
|
||||||
|
interruptionLevel: switch (strength) {
|
||||||
|
rust.BridgePokeStrength.strong => InterruptionLevel.timeSensitive,
|
||||||
|
rust.BridgePokeStrength.suppressed => InterruptionLevel.active,
|
||||||
|
rust.BridgePokeStrength.suppressedOverflow => InterruptionLevel.passive,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
LinuxNotificationDetails _linuxDetails(rust.BridgePokeStrength strength) {
|
||||||
|
return LinuxNotificationDetails(
|
||||||
|
// TODO(event-sounds): handled by future EventSoundService, not the OS channel.
|
||||||
|
suppressSound: true,
|
||||||
|
urgency: switch (strength) {
|
||||||
|
rust.BridgePokeStrength.strong => LinuxNotificationUrgency.critical,
|
||||||
|
rust.BridgePokeStrength.suppressed => LinuxNotificationUrgency.normal,
|
||||||
|
rust.BridgePokeStrength.suppressedOverflow =>
|
||||||
|
LinuxNotificationUrgency.low,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
WindowsNotificationDetails _windowsDetails(rust.BridgePokeStrength strength) {
|
||||||
|
return WindowsNotificationDetails(
|
||||||
|
// TODO(event-sounds): handled by future EventSoundService, not the OS channel.
|
||||||
|
audio: WindowsNotificationAudio.silent(),
|
||||||
|
header: _windowsHeader,
|
||||||
|
scenario: strength == rust.BridgePokeStrength.strong
|
||||||
|
? WindowsNotificationScenario.urgent
|
||||||
|
: null,
|
||||||
|
duration: strength == rust.BridgePokeStrength.strong
|
||||||
|
? WindowsNotificationDuration.long
|
||||||
|
: WindowsNotificationDuration.short,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
|
class PokePreferencesService {
|
||||||
|
static const _enabledKey = 'pokes.enabled';
|
||||||
|
static const _mutedSendersKey = 'pokes.muted_senders';
|
||||||
|
|
||||||
|
final ValueNotifier<bool> _pokesEnabled = ValueNotifier<bool>(true);
|
||||||
|
final ValueNotifier<Set<BigInt>> _mutedSenders = ValueNotifier<Set<BigInt>>(
|
||||||
|
const <BigInt>{},
|
||||||
|
);
|
||||||
|
|
||||||
|
ValueListenable<bool> get pokesEnabled => _pokesEnabled;
|
||||||
|
ValueListenable<Set<BigInt>> get mutedSenders => _mutedSenders;
|
||||||
|
|
||||||
|
Future<void> load() async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
_pokesEnabled.value = prefs.getBool(_enabledKey) ?? true;
|
||||||
|
_mutedSenders.value = (prefs.getStringList(_mutedSendersKey) ?? const [])
|
||||||
|
.map(BigInt.parse)
|
||||||
|
.toSet();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> setPokesEnabled(bool enabled) async {
|
||||||
|
_pokesEnabled.value = enabled;
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
await prefs.setBool(_enabledKey, enabled);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> muteSender(BigInt senderId) async {
|
||||||
|
if (_mutedSenders.value.contains(senderId)) return;
|
||||||
|
_mutedSenders.value = {..._mutedSenders.value, senderId};
|
||||||
|
await _saveMutedSenders();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> unmuteSender(BigInt senderId) async {
|
||||||
|
if (!_mutedSenders.value.contains(senderId)) return;
|
||||||
|
_mutedSenders.value = _mutedSenders.value
|
||||||
|
.where((mutedSender) => mutedSender != senderId)
|
||||||
|
.toSet();
|
||||||
|
await _saveMutedSenders();
|
||||||
|
}
|
||||||
|
|
||||||
|
bool isMuted(BigInt senderId) => _mutedSenders.value.contains(senderId);
|
||||||
|
|
||||||
|
Future<void> _saveMutedSenders() async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
await prefs.setStringList(
|
||||||
|
_mutedSendersKey,
|
||||||
|
_mutedSenders.value.map((senderId) => senderId.toString()).toList(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void dispose() {
|
||||||
|
_pokesEnabled.dispose();
|
||||||
|
_mutedSenders.dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,7 +11,7 @@ part 'api.freezed.dart';
|
|||||||
|
|
||||||
// These functions are ignored because they are not marked as `pub`: `dispatch_platform_audio_event`, `install_panic_diagnostic_hook`, `log_file_path`, `log_sink`, `map_join_error_code`, `map_join_sync_state`, `open_log_file`, `permission_events`, `platform_audio_events`, `process`, `publish_permission_state`, `runtime`, `session`, `task_join_error`, `transmit_mode_from_u8`
|
// These functions are ignored because they are not marked as `pub`: `dispatch_platform_audio_event`, `install_panic_diagnostic_hook`, `log_file_path`, `log_sink`, `map_join_error_code`, `map_join_sync_state`, `open_log_file`, `permission_events`, `platform_audio_events`, `process`, `publish_permission_state`, `runtime`, `session`, `task_join_error`, `transmit_mode_from_u8`
|
||||||
// These types are ignored because they are neither used by any `pub` functions nor (for structs and enums) marked `#[frb(unignore)]`: `PlatformAudioEvent`
|
// These types are ignored because they are neither used by any `pub` functions nor (for structs and enums) marked `#[frb(unignore)]`: `PlatformAudioEvent`
|
||||||
// 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 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`, `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`, `clone`, `eq`, `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`, `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`, `from`
|
||||||
// These functions are ignored (category: IgnoreBecauseExplicitAttribute): `from_kotlin_str`, `to_permission_gate`
|
// These functions are ignored (category: IgnoreBecauseExplicitAttribute): `from_kotlin_str`, `to_permission_gate`
|
||||||
|
|
||||||
/// Return the platform-conventional log-file path as a string, or
|
/// Return the platform-conventional log-file path as a string, or
|
||||||
@@ -263,7 +263,8 @@ Future<BridgeAudioStats> audioStats() =>
|
|||||||
|
|
||||||
/// Subscribe to real-time microphone input level at ~30 Hz.
|
/// Subscribe to real-time microphone input level at ~30 Hz.
|
||||||
/// Values are dBFS (-120 = silence, 0 = clipping). The stream ends
|
/// Values are dBFS (-120 = silence, 0 = clipping). The stream ends
|
||||||
/// when the Dart subscriber cancels or the session is dropped.
|
/// when the Dart subscriber cancels, the session is dropped, or
|
||||||
|
/// the session becomes persistently unavailable.
|
||||||
Stream<double> inputLevelStream() =>
|
Stream<double> inputLevelStream() =>
|
||||||
RustLib.instance.api.crateApiInputLevelStream();
|
RustLib.instance.api.crateApiInputLevelStream();
|
||||||
|
|
||||||
@@ -1209,6 +1210,9 @@ sealed class BridgeEvent with _$BridgeEvent {
|
|||||||
|
|
||||||
/// Target scope (server/channel/private/poke).
|
/// Target scope (server/channel/private/poke).
|
||||||
required BridgeMessageTarget target,
|
required BridgeMessageTarget target,
|
||||||
|
|
||||||
|
/// Poke notification strength, present only for poke messages.
|
||||||
|
BridgePokeStrength? pokeStrength,
|
||||||
}) = BridgeEvent_ChatMessage;
|
}) = BridgeEvent_ChatMessage;
|
||||||
|
|
||||||
/// Human-readable server activity surfaced from protocol bookkeeping events.
|
/// Human-readable server activity surfaced from protocol bookkeeping events.
|
||||||
@@ -1219,48 +1223,116 @@ sealed class BridgeEvent with _$BridgeEvent {
|
|||||||
|
|
||||||
/// Audio route changed (speaker/earpiece/BT/wired).
|
/// Audio route changed (speaker/earpiece/BT/wired).
|
||||||
const factory BridgeEvent.audioRouteChanged({
|
const factory BridgeEvent.audioRouteChanged({
|
||||||
|
/// New audio output route.
|
||||||
required BridgeAudioRoute route,
|
required BridgeAudioRoute route,
|
||||||
}) = BridgeEvent_AudioRouteChanged;
|
}) = BridgeEvent_AudioRouteChanged;
|
||||||
|
|
||||||
|
/// A client moved to a different channel.
|
||||||
const factory BridgeEvent.clientMoved({
|
const factory BridgeEvent.clientMoved({
|
||||||
|
/// Unique client identifier.
|
||||||
required BigInt clientId,
|
required BigInt clientId,
|
||||||
|
|
||||||
|
/// Destination channel.
|
||||||
required BigInt newChannelId,
|
required BigInt newChannelId,
|
||||||
}) = BridgeEvent_ClientMoved;
|
}) = BridgeEvent_ClientMoved;
|
||||||
|
|
||||||
|
/// A new client connected.
|
||||||
const factory BridgeEvent.clientJoined({
|
const factory BridgeEvent.clientJoined({
|
||||||
|
/// Unique client identifier.
|
||||||
required BigInt clientId,
|
required BigInt clientId,
|
||||||
|
|
||||||
|
/// Channel the client joined.
|
||||||
required BigInt channelId,
|
required BigInt channelId,
|
||||||
|
|
||||||
|
/// Display nickname.
|
||||||
required String name,
|
required String name,
|
||||||
|
|
||||||
|
/// Microphone muted state.
|
||||||
required bool inputMuted,
|
required bool inputMuted,
|
||||||
|
|
||||||
|
/// Speaker muted state.
|
||||||
required bool outputMuted,
|
required bool outputMuted,
|
||||||
|
|
||||||
|
/// True for server query (bot) clients.
|
||||||
required bool isServerQuery,
|
required bool isServerQuery,
|
||||||
|
|
||||||
|
/// Client's talk power value.
|
||||||
required int talkPower,
|
required int talkPower,
|
||||||
|
|
||||||
|
/// Whether the server granted temporary talk power.
|
||||||
required bool talkPowerGranted,
|
required bool talkPowerGranted,
|
||||||
}) = BridgeEvent_ClientJoined;
|
}) = BridgeEvent_ClientJoined;
|
||||||
|
|
||||||
|
/// A client disconnected.
|
||||||
const factory BridgeEvent.clientLeft({
|
const factory BridgeEvent.clientLeft({
|
||||||
|
/// Unique client identifier.
|
||||||
required BigInt clientId,
|
required BigInt clientId,
|
||||||
|
|
||||||
|
/// Display nickname at time of disconnect.
|
||||||
required String name,
|
required String name,
|
||||||
}) = BridgeEvent_ClientLeft;
|
}) = BridgeEvent_ClientLeft;
|
||||||
|
|
||||||
|
/// Client properties changed.
|
||||||
const factory BridgeEvent.clientUpdated({
|
const factory BridgeEvent.clientUpdated({
|
||||||
|
/// Unique client identifier.
|
||||||
required BigInt clientId,
|
required BigInt clientId,
|
||||||
|
|
||||||
|
/// Microphone muted state.
|
||||||
required bool inputMuted,
|
required bool inputMuted,
|
||||||
|
|
||||||
|
/// Speaker muted state.
|
||||||
required bool outputMuted,
|
required bool outputMuted,
|
||||||
|
|
||||||
|
/// True for server query (bot) clients.
|
||||||
required bool isServerQuery,
|
required bool isServerQuery,
|
||||||
|
|
||||||
|
/// Client's talk power value.
|
||||||
required int talkPower,
|
required int talkPower,
|
||||||
|
|
||||||
|
/// Whether the server granted temporary talk power.
|
||||||
required bool talkPowerGranted,
|
required bool talkPowerGranted,
|
||||||
}) = BridgeEvent_ClientUpdated;
|
}) = BridgeEvent_ClientUpdated;
|
||||||
|
|
||||||
|
/// A new channel appeared.
|
||||||
const factory BridgeEvent.channelAdded({
|
const factory BridgeEvent.channelAdded({
|
||||||
|
/// Unique channel identifier.
|
||||||
required BigInt id,
|
required BigInt id,
|
||||||
|
|
||||||
|
/// Parent channel ID.
|
||||||
required BigInt parent,
|
required BigInt parent,
|
||||||
|
|
||||||
|
/// Channel name.
|
||||||
required String name,
|
required String name,
|
||||||
|
|
||||||
|
/// Predecessor channel ID within the same parent (TeamSpeak
|
||||||
|
/// linked-list ordering hint). Zero means first child.
|
||||||
required PlatformInt64 order,
|
required PlatformInt64 order,
|
||||||
|
|
||||||
|
/// Whether the channel requires a password.
|
||||||
required bool hasPassword,
|
required bool hasPassword,
|
||||||
|
|
||||||
|
/// Talk power required to speak; `None` means no restriction.
|
||||||
int? neededTalkPower,
|
int? neededTalkPower,
|
||||||
}) = BridgeEvent_ChannelAdded;
|
}) = BridgeEvent_ChannelAdded;
|
||||||
const factory BridgeEvent.channelRemoved({required BigInt id}) =
|
|
||||||
BridgeEvent_ChannelRemoved;
|
/// A channel was deleted.
|
||||||
const factory BridgeEvent.channelUpdated({
|
const factory BridgeEvent.channelRemoved({
|
||||||
|
/// Channel identifier.
|
||||||
required BigInt id,
|
required BigInt id,
|
||||||
|
}) = BridgeEvent_ChannelRemoved;
|
||||||
|
|
||||||
|
/// Channel properties changed.
|
||||||
|
const factory BridgeEvent.channelUpdated({
|
||||||
|
/// Unique channel identifier.
|
||||||
|
required BigInt id,
|
||||||
|
|
||||||
|
/// Channel name.
|
||||||
required String name,
|
required String name,
|
||||||
|
|
||||||
|
/// Whether the channel requires a password.
|
||||||
required bool hasPassword,
|
required bool hasPassword,
|
||||||
|
|
||||||
|
/// Talk power required to speak; `None` means no restriction.
|
||||||
int? neededTalkPower,
|
int? neededTalkPower,
|
||||||
}) = BridgeEvent_ChannelUpdated;
|
}) = BridgeEvent_ChannelUpdated;
|
||||||
}
|
}
|
||||||
@@ -1306,6 +1378,18 @@ enum BridgeNetworkState {
|
|||||||
offline,
|
offline,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Bridge poke notification strength.
|
||||||
|
enum BridgePokeStrength {
|
||||||
|
/// Poke should be surfaced at full strength.
|
||||||
|
strong,
|
||||||
|
|
||||||
|
/// Poke is rate-limited but below overflow severity.
|
||||||
|
suppressed,
|
||||||
|
|
||||||
|
/// Poke remains suppressed after repeated suppressed pokes.
|
||||||
|
suppressedOverflow,
|
||||||
|
}
|
||||||
|
|
||||||
/// Persisted PTT binding display state for the UI.
|
/// Persisted PTT binding display state for the UI.
|
||||||
class BridgePttBinding {
|
class BridgePttBinding {
|
||||||
/// Stable input category string (`""`, `"keyboard"`, or
|
/// Stable input category string (`""`, `"keyboard"`, or
|
||||||
|
|||||||
@@ -173,7 +173,7 @@ return channelUpdated(_that);case _:
|
|||||||
/// }
|
/// }
|
||||||
/// ```
|
/// ```
|
||||||
|
|
||||||
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>({TResult Function( String serverName)? connected,TResult Function( String reason)? lost,TResult Function( int attempt, int delaySecs)? reconnecting,TResult Function( String reason)? disconnected,TResult Function()? audioStarted,TResult Function()? audioStopped,TResult Function( String level, String backendId, String boundInputClass)? pttCapability,TResult Function( bool inChannel, BridgeTransmitMode transmitMode, bool mute, int releaseTailMs, BigInt? currentChannelId, BigInt? pendingTargetChannelId, bool canJoin, bool canLeave, BridgeVoiceJoinSyncState joinSyncState, BridgeVoiceJoinErrorCode? joinErrorCode)? voiceState,TResult Function( bool began, bool shouldResume)? interruptionState,TResult Function( String permission, PermissionStateKind state)? permissionState,TResult Function( BigInt senderId, String senderName, String message, BridgeMessageTarget target)? chatMessage,TResult Function( String message)? serverActivity,TResult Function( BridgeAudioRoute route)? audioRouteChanged,TResult Function( BigInt clientId, BigInt newChannelId)? clientMoved,TResult Function( BigInt clientId, BigInt channelId, String name, bool inputMuted, bool outputMuted, bool isServerQuery, int talkPower, bool talkPowerGranted)? clientJoined,TResult Function( BigInt clientId, String name)? clientLeft,TResult Function( BigInt clientId, bool inputMuted, bool outputMuted, bool isServerQuery, int talkPower, bool talkPowerGranted)? clientUpdated,TResult Function( BigInt id, BigInt parent, String name, PlatformInt64 order, bool hasPassword, int? neededTalkPower)? channelAdded,TResult Function( BigInt id)? channelRemoved,TResult Function( BigInt id, String name, bool hasPassword, int? neededTalkPower)? channelUpdated,required TResult orElse(),}) {final _that = this;
|
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>({TResult Function( String serverName)? connected,TResult Function( String reason)? lost,TResult Function( int attempt, int delaySecs)? reconnecting,TResult Function( String reason)? disconnected,TResult Function()? audioStarted,TResult Function()? audioStopped,TResult Function( String level, String backendId, String boundInputClass)? pttCapability,TResult Function( bool inChannel, BridgeTransmitMode transmitMode, bool mute, int releaseTailMs, BigInt? currentChannelId, BigInt? pendingTargetChannelId, bool canJoin, bool canLeave, BridgeVoiceJoinSyncState joinSyncState, BridgeVoiceJoinErrorCode? joinErrorCode)? voiceState,TResult Function( bool began, bool shouldResume)? interruptionState,TResult Function( String permission, PermissionStateKind state)? permissionState,TResult Function( BigInt senderId, String senderName, String message, BridgeMessageTarget target, BridgePokeStrength? pokeStrength)? chatMessage,TResult Function( String message)? serverActivity,TResult Function( BridgeAudioRoute route)? audioRouteChanged,TResult Function( BigInt clientId, BigInt newChannelId)? clientMoved,TResult Function( BigInt clientId, BigInt channelId, String name, bool inputMuted, bool outputMuted, bool isServerQuery, int talkPower, bool talkPowerGranted)? clientJoined,TResult Function( BigInt clientId, String name)? clientLeft,TResult Function( BigInt clientId, bool inputMuted, bool outputMuted, bool isServerQuery, int talkPower, bool talkPowerGranted)? clientUpdated,TResult Function( BigInt id, BigInt parent, String name, PlatformInt64 order, bool hasPassword, int? neededTalkPower)? channelAdded,TResult Function( BigInt id)? channelRemoved,TResult Function( BigInt id, String name, bool hasPassword, int? neededTalkPower)? channelUpdated,required TResult orElse(),}) {final _that = this;
|
||||||
switch (_that) {
|
switch (_that) {
|
||||||
case BridgeEvent_Connected() when connected != null:
|
case BridgeEvent_Connected() when connected != null:
|
||||||
return connected(_that.serverName);case BridgeEvent_Lost() when lost != null:
|
return connected(_that.serverName);case BridgeEvent_Lost() when lost != null:
|
||||||
@@ -186,7 +186,7 @@ return pttCapability(_that.level,_that.backendId,_that.boundInputClass);case Bri
|
|||||||
return voiceState(_that.inChannel,_that.transmitMode,_that.mute,_that.releaseTailMs,_that.currentChannelId,_that.pendingTargetChannelId,_that.canJoin,_that.canLeave,_that.joinSyncState,_that.joinErrorCode);case BridgeEvent_InterruptionState() when interruptionState != null:
|
return voiceState(_that.inChannel,_that.transmitMode,_that.mute,_that.releaseTailMs,_that.currentChannelId,_that.pendingTargetChannelId,_that.canJoin,_that.canLeave,_that.joinSyncState,_that.joinErrorCode);case BridgeEvent_InterruptionState() when interruptionState != null:
|
||||||
return interruptionState(_that.began,_that.shouldResume);case BridgeEvent_PermissionState() when permissionState != null:
|
return interruptionState(_that.began,_that.shouldResume);case BridgeEvent_PermissionState() when permissionState != null:
|
||||||
return permissionState(_that.permission,_that.state);case BridgeEvent_ChatMessage() when chatMessage != null:
|
return permissionState(_that.permission,_that.state);case BridgeEvent_ChatMessage() when chatMessage != null:
|
||||||
return chatMessage(_that.senderId,_that.senderName,_that.message,_that.target);case BridgeEvent_ServerActivity() when serverActivity != null:
|
return chatMessage(_that.senderId,_that.senderName,_that.message,_that.target,_that.pokeStrength);case BridgeEvent_ServerActivity() when serverActivity != null:
|
||||||
return serverActivity(_that.message);case BridgeEvent_AudioRouteChanged() when audioRouteChanged != null:
|
return serverActivity(_that.message);case BridgeEvent_AudioRouteChanged() when audioRouteChanged != null:
|
||||||
return audioRouteChanged(_that.route);case BridgeEvent_ClientMoved() when clientMoved != null:
|
return audioRouteChanged(_that.route);case BridgeEvent_ClientMoved() when clientMoved != null:
|
||||||
return clientMoved(_that.clientId,_that.newChannelId);case BridgeEvent_ClientJoined() when clientJoined != null:
|
return clientMoved(_that.clientId,_that.newChannelId);case BridgeEvent_ClientJoined() when clientJoined != null:
|
||||||
@@ -213,7 +213,7 @@ return channelUpdated(_that.id,_that.name,_that.hasPassword,_that.neededTalkPowe
|
|||||||
/// }
|
/// }
|
||||||
/// ```
|
/// ```
|
||||||
|
|
||||||
@optionalTypeArgs TResult when<TResult extends Object?>({required TResult Function( String serverName) connected,required TResult Function( String reason) lost,required TResult Function( int attempt, int delaySecs) reconnecting,required TResult Function( String reason) disconnected,required TResult Function() audioStarted,required TResult Function() audioStopped,required TResult Function( String level, String backendId, String boundInputClass) pttCapability,required TResult Function( bool inChannel, BridgeTransmitMode transmitMode, bool mute, int releaseTailMs, BigInt? currentChannelId, BigInt? pendingTargetChannelId, bool canJoin, bool canLeave, BridgeVoiceJoinSyncState joinSyncState, BridgeVoiceJoinErrorCode? joinErrorCode) voiceState,required TResult Function( bool began, bool shouldResume) interruptionState,required TResult Function( String permission, PermissionStateKind state) permissionState,required TResult Function( BigInt senderId, String senderName, String message, BridgeMessageTarget target) chatMessage,required TResult Function( String message) serverActivity,required TResult Function( BridgeAudioRoute route) audioRouteChanged,required TResult Function( BigInt clientId, BigInt newChannelId) clientMoved,required TResult Function( BigInt clientId, BigInt channelId, String name, bool inputMuted, bool outputMuted, bool isServerQuery, int talkPower, bool talkPowerGranted) clientJoined,required TResult Function( BigInt clientId, String name) clientLeft,required TResult Function( BigInt clientId, bool inputMuted, bool outputMuted, bool isServerQuery, int talkPower, bool talkPowerGranted) clientUpdated,required TResult Function( BigInt id, BigInt parent, String name, PlatformInt64 order, bool hasPassword, int? neededTalkPower) channelAdded,required TResult Function( BigInt id) channelRemoved,required TResult Function( BigInt id, String name, bool hasPassword, int? neededTalkPower) channelUpdated,}) {final _that = this;
|
@optionalTypeArgs TResult when<TResult extends Object?>({required TResult Function( String serverName) connected,required TResult Function( String reason) lost,required TResult Function( int attempt, int delaySecs) reconnecting,required TResult Function( String reason) disconnected,required TResult Function() audioStarted,required TResult Function() audioStopped,required TResult Function( String level, String backendId, String boundInputClass) pttCapability,required TResult Function( bool inChannel, BridgeTransmitMode transmitMode, bool mute, int releaseTailMs, BigInt? currentChannelId, BigInt? pendingTargetChannelId, bool canJoin, bool canLeave, BridgeVoiceJoinSyncState joinSyncState, BridgeVoiceJoinErrorCode? joinErrorCode) voiceState,required TResult Function( bool began, bool shouldResume) interruptionState,required TResult Function( String permission, PermissionStateKind state) permissionState,required TResult Function( BigInt senderId, String senderName, String message, BridgeMessageTarget target, BridgePokeStrength? pokeStrength) chatMessage,required TResult Function( String message) serverActivity,required TResult Function( BridgeAudioRoute route) audioRouteChanged,required TResult Function( BigInt clientId, BigInt newChannelId) clientMoved,required TResult Function( BigInt clientId, BigInt channelId, String name, bool inputMuted, bool outputMuted, bool isServerQuery, int talkPower, bool talkPowerGranted) clientJoined,required TResult Function( BigInt clientId, String name) clientLeft,required TResult Function( BigInt clientId, bool inputMuted, bool outputMuted, bool isServerQuery, int talkPower, bool talkPowerGranted) clientUpdated,required TResult Function( BigInt id, BigInt parent, String name, PlatformInt64 order, bool hasPassword, int? neededTalkPower) channelAdded,required TResult Function( BigInt id) channelRemoved,required TResult Function( BigInt id, String name, bool hasPassword, int? neededTalkPower) channelUpdated,}) {final _that = this;
|
||||||
switch (_that) {
|
switch (_that) {
|
||||||
case BridgeEvent_Connected():
|
case BridgeEvent_Connected():
|
||||||
return connected(_that.serverName);case BridgeEvent_Lost():
|
return connected(_that.serverName);case BridgeEvent_Lost():
|
||||||
@@ -226,7 +226,7 @@ return pttCapability(_that.level,_that.backendId,_that.boundInputClass);case Bri
|
|||||||
return voiceState(_that.inChannel,_that.transmitMode,_that.mute,_that.releaseTailMs,_that.currentChannelId,_that.pendingTargetChannelId,_that.canJoin,_that.canLeave,_that.joinSyncState,_that.joinErrorCode);case BridgeEvent_InterruptionState():
|
return voiceState(_that.inChannel,_that.transmitMode,_that.mute,_that.releaseTailMs,_that.currentChannelId,_that.pendingTargetChannelId,_that.canJoin,_that.canLeave,_that.joinSyncState,_that.joinErrorCode);case BridgeEvent_InterruptionState():
|
||||||
return interruptionState(_that.began,_that.shouldResume);case BridgeEvent_PermissionState():
|
return interruptionState(_that.began,_that.shouldResume);case BridgeEvent_PermissionState():
|
||||||
return permissionState(_that.permission,_that.state);case BridgeEvent_ChatMessage():
|
return permissionState(_that.permission,_that.state);case BridgeEvent_ChatMessage():
|
||||||
return chatMessage(_that.senderId,_that.senderName,_that.message,_that.target);case BridgeEvent_ServerActivity():
|
return chatMessage(_that.senderId,_that.senderName,_that.message,_that.target,_that.pokeStrength);case BridgeEvent_ServerActivity():
|
||||||
return serverActivity(_that.message);case BridgeEvent_AudioRouteChanged():
|
return serverActivity(_that.message);case BridgeEvent_AudioRouteChanged():
|
||||||
return audioRouteChanged(_that.route);case BridgeEvent_ClientMoved():
|
return audioRouteChanged(_that.route);case BridgeEvent_ClientMoved():
|
||||||
return clientMoved(_that.clientId,_that.newChannelId);case BridgeEvent_ClientJoined():
|
return clientMoved(_that.clientId,_that.newChannelId);case BridgeEvent_ClientJoined():
|
||||||
@@ -249,7 +249,7 @@ return channelUpdated(_that.id,_that.name,_that.hasPassword,_that.neededTalkPowe
|
|||||||
/// }
|
/// }
|
||||||
/// ```
|
/// ```
|
||||||
|
|
||||||
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>({TResult? Function( String serverName)? connected,TResult? Function( String reason)? lost,TResult? Function( int attempt, int delaySecs)? reconnecting,TResult? Function( String reason)? disconnected,TResult? Function()? audioStarted,TResult? Function()? audioStopped,TResult? Function( String level, String backendId, String boundInputClass)? pttCapability,TResult? Function( bool inChannel, BridgeTransmitMode transmitMode, bool mute, int releaseTailMs, BigInt? currentChannelId, BigInt? pendingTargetChannelId, bool canJoin, bool canLeave, BridgeVoiceJoinSyncState joinSyncState, BridgeVoiceJoinErrorCode? joinErrorCode)? voiceState,TResult? Function( bool began, bool shouldResume)? interruptionState,TResult? Function( String permission, PermissionStateKind state)? permissionState,TResult? Function( BigInt senderId, String senderName, String message, BridgeMessageTarget target)? chatMessage,TResult? Function( String message)? serverActivity,TResult? Function( BridgeAudioRoute route)? audioRouteChanged,TResult? Function( BigInt clientId, BigInt newChannelId)? clientMoved,TResult? Function( BigInt clientId, BigInt channelId, String name, bool inputMuted, bool outputMuted, bool isServerQuery, int talkPower, bool talkPowerGranted)? clientJoined,TResult? Function( BigInt clientId, String name)? clientLeft,TResult? Function( BigInt clientId, bool inputMuted, bool outputMuted, bool isServerQuery, int talkPower, bool talkPowerGranted)? clientUpdated,TResult? Function( BigInt id, BigInt parent, String name, PlatformInt64 order, bool hasPassword, int? neededTalkPower)? channelAdded,TResult? Function( BigInt id)? channelRemoved,TResult? Function( BigInt id, String name, bool hasPassword, int? neededTalkPower)? channelUpdated,}) {final _that = this;
|
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>({TResult? Function( String serverName)? connected,TResult? Function( String reason)? lost,TResult? Function( int attempt, int delaySecs)? reconnecting,TResult? Function( String reason)? disconnected,TResult? Function()? audioStarted,TResult? Function()? audioStopped,TResult? Function( String level, String backendId, String boundInputClass)? pttCapability,TResult? Function( bool inChannel, BridgeTransmitMode transmitMode, bool mute, int releaseTailMs, BigInt? currentChannelId, BigInt? pendingTargetChannelId, bool canJoin, bool canLeave, BridgeVoiceJoinSyncState joinSyncState, BridgeVoiceJoinErrorCode? joinErrorCode)? voiceState,TResult? Function( bool began, bool shouldResume)? interruptionState,TResult? Function( String permission, PermissionStateKind state)? permissionState,TResult? Function( BigInt senderId, String senderName, String message, BridgeMessageTarget target, BridgePokeStrength? pokeStrength)? chatMessage,TResult? Function( String message)? serverActivity,TResult? Function( BridgeAudioRoute route)? audioRouteChanged,TResult? Function( BigInt clientId, BigInt newChannelId)? clientMoved,TResult? Function( BigInt clientId, BigInt channelId, String name, bool inputMuted, bool outputMuted, bool isServerQuery, int talkPower, bool talkPowerGranted)? clientJoined,TResult? Function( BigInt clientId, String name)? clientLeft,TResult? Function( BigInt clientId, bool inputMuted, bool outputMuted, bool isServerQuery, int talkPower, bool talkPowerGranted)? clientUpdated,TResult? Function( BigInt id, BigInt parent, String name, PlatformInt64 order, bool hasPassword, int? neededTalkPower)? channelAdded,TResult? Function( BigInt id)? channelRemoved,TResult? Function( BigInt id, String name, bool hasPassword, int? neededTalkPower)? channelUpdated,}) {final _that = this;
|
||||||
switch (_that) {
|
switch (_that) {
|
||||||
case BridgeEvent_Connected() when connected != null:
|
case BridgeEvent_Connected() when connected != null:
|
||||||
return connected(_that.serverName);case BridgeEvent_Lost() when lost != null:
|
return connected(_that.serverName);case BridgeEvent_Lost() when lost != null:
|
||||||
@@ -262,7 +262,7 @@ return pttCapability(_that.level,_that.backendId,_that.boundInputClass);case Bri
|
|||||||
return voiceState(_that.inChannel,_that.transmitMode,_that.mute,_that.releaseTailMs,_that.currentChannelId,_that.pendingTargetChannelId,_that.canJoin,_that.canLeave,_that.joinSyncState,_that.joinErrorCode);case BridgeEvent_InterruptionState() when interruptionState != null:
|
return voiceState(_that.inChannel,_that.transmitMode,_that.mute,_that.releaseTailMs,_that.currentChannelId,_that.pendingTargetChannelId,_that.canJoin,_that.canLeave,_that.joinSyncState,_that.joinErrorCode);case BridgeEvent_InterruptionState() when interruptionState != null:
|
||||||
return interruptionState(_that.began,_that.shouldResume);case BridgeEvent_PermissionState() when permissionState != null:
|
return interruptionState(_that.began,_that.shouldResume);case BridgeEvent_PermissionState() when permissionState != null:
|
||||||
return permissionState(_that.permission,_that.state);case BridgeEvent_ChatMessage() when chatMessage != null:
|
return permissionState(_that.permission,_that.state);case BridgeEvent_ChatMessage() when chatMessage != null:
|
||||||
return chatMessage(_that.senderId,_that.senderName,_that.message,_that.target);case BridgeEvent_ServerActivity() when serverActivity != null:
|
return chatMessage(_that.senderId,_that.senderName,_that.message,_that.target,_that.pokeStrength);case BridgeEvent_ServerActivity() when serverActivity != null:
|
||||||
return serverActivity(_that.message);case BridgeEvent_AudioRouteChanged() when audioRouteChanged != null:
|
return serverActivity(_that.message);case BridgeEvent_AudioRouteChanged() when audioRouteChanged != null:
|
||||||
return audioRouteChanged(_that.route);case BridgeEvent_ClientMoved() when clientMoved != null:
|
return audioRouteChanged(_that.route);case BridgeEvent_ClientMoved() when clientMoved != null:
|
||||||
return clientMoved(_that.clientId,_that.newChannelId);case BridgeEvent_ClientJoined() when clientJoined != null:
|
return clientMoved(_that.clientId,_that.newChannelId);case BridgeEvent_ClientJoined() when clientJoined != null:
|
||||||
@@ -929,7 +929,7 @@ as PermissionStateKind,
|
|||||||
|
|
||||||
|
|
||||||
class BridgeEvent_ChatMessage extends BridgeEvent {
|
class BridgeEvent_ChatMessage extends BridgeEvent {
|
||||||
const BridgeEvent_ChatMessage({required this.senderId, required this.senderName, required this.message, required this.target}): super._();
|
const BridgeEvent_ChatMessage({required this.senderId, required this.senderName, required this.message, required this.target, this.pokeStrength}): super._();
|
||||||
|
|
||||||
|
|
||||||
/// Client id of the sender.
|
/// Client id of the sender.
|
||||||
@@ -940,6 +940,8 @@ class BridgeEvent_ChatMessage extends BridgeEvent {
|
|||||||
final String message;
|
final String message;
|
||||||
/// Target scope (server/channel/private/poke).
|
/// Target scope (server/channel/private/poke).
|
||||||
final BridgeMessageTarget target;
|
final BridgeMessageTarget target;
|
||||||
|
/// Poke notification strength, present only for poke messages.
|
||||||
|
final BridgePokeStrength? pokeStrength;
|
||||||
|
|
||||||
/// Create a copy of BridgeEvent
|
/// Create a copy of BridgeEvent
|
||||||
/// with the given fields replaced by the non-null parameter values.
|
/// with the given fields replaced by the non-null parameter values.
|
||||||
@@ -951,16 +953,16 @@ $BridgeEvent_ChatMessageCopyWith<BridgeEvent_ChatMessage> get copyWith => _$Brid
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
bool operator ==(Object other) {
|
bool operator ==(Object other) {
|
||||||
return identical(this, other) || (other.runtimeType == runtimeType&&other is BridgeEvent_ChatMessage&&(identical(other.senderId, senderId) || other.senderId == senderId)&&(identical(other.senderName, senderName) || other.senderName == senderName)&&(identical(other.message, message) || other.message == message)&&(identical(other.target, target) || other.target == target));
|
return identical(this, other) || (other.runtimeType == runtimeType&&other is BridgeEvent_ChatMessage&&(identical(other.senderId, senderId) || other.senderId == senderId)&&(identical(other.senderName, senderName) || other.senderName == senderName)&&(identical(other.message, message) || other.message == message)&&(identical(other.target, target) || other.target == target)&&(identical(other.pokeStrength, pokeStrength) || other.pokeStrength == pokeStrength));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
int get hashCode => Object.hash(runtimeType,senderId,senderName,message,target);
|
int get hashCode => Object.hash(runtimeType,senderId,senderName,message,target,pokeStrength);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String toString() {
|
String toString() {
|
||||||
return 'BridgeEvent.chatMessage(senderId: $senderId, senderName: $senderName, message: $message, target: $target)';
|
return 'BridgeEvent.chatMessage(senderId: $senderId, senderName: $senderName, message: $message, target: $target, pokeStrength: $pokeStrength)';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -971,7 +973,7 @@ abstract mixin class $BridgeEvent_ChatMessageCopyWith<$Res> implements $BridgeEv
|
|||||||
factory $BridgeEvent_ChatMessageCopyWith(BridgeEvent_ChatMessage value, $Res Function(BridgeEvent_ChatMessage) _then) = _$BridgeEvent_ChatMessageCopyWithImpl;
|
factory $BridgeEvent_ChatMessageCopyWith(BridgeEvent_ChatMessage value, $Res Function(BridgeEvent_ChatMessage) _then) = _$BridgeEvent_ChatMessageCopyWithImpl;
|
||||||
@useResult
|
@useResult
|
||||||
$Res call({
|
$Res call({
|
||||||
BigInt senderId, String senderName, String message, BridgeMessageTarget target
|
BigInt senderId, String senderName, String message, BridgeMessageTarget target, BridgePokeStrength? pokeStrength
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
@@ -988,13 +990,14 @@ class _$BridgeEvent_ChatMessageCopyWithImpl<$Res>
|
|||||||
|
|
||||||
/// Create a copy of BridgeEvent
|
/// Create a copy of BridgeEvent
|
||||||
/// with the given fields replaced by the non-null parameter values.
|
/// with the given fields replaced by the non-null parameter values.
|
||||||
@pragma('vm:prefer-inline') $Res call({Object? senderId = null,Object? senderName = null,Object? message = null,Object? target = null,}) {
|
@pragma('vm:prefer-inline') $Res call({Object? senderId = null,Object? senderName = null,Object? message = null,Object? target = null,Object? pokeStrength = freezed,}) {
|
||||||
return _then(BridgeEvent_ChatMessage(
|
return _then(BridgeEvent_ChatMessage(
|
||||||
senderId: null == senderId ? _self.senderId : senderId // ignore: cast_nullable_to_non_nullable
|
senderId: null == senderId ? _self.senderId : senderId // ignore: cast_nullable_to_non_nullable
|
||||||
as BigInt,senderName: null == senderName ? _self.senderName : senderName // ignore: cast_nullable_to_non_nullable
|
as BigInt,senderName: null == senderName ? _self.senderName : senderName // ignore: cast_nullable_to_non_nullable
|
||||||
as String,message: null == message ? _self.message : message // ignore: cast_nullable_to_non_nullable
|
as String,message: null == message ? _self.message : message // ignore: cast_nullable_to_non_nullable
|
||||||
as String,target: null == target ? _self.target : target // ignore: cast_nullable_to_non_nullable
|
as String,target: null == target ? _self.target : target // ignore: cast_nullable_to_non_nullable
|
||||||
as BridgeMessageTarget,
|
as BridgeMessageTarget,pokeStrength: freezed == pokeStrength ? _self.pokeStrength : pokeStrength // ignore: cast_nullable_to_non_nullable
|
||||||
|
as BridgePokeStrength?,
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1084,6 +1087,7 @@ class BridgeEvent_AudioRouteChanged extends BridgeEvent {
|
|||||||
const BridgeEvent_AudioRouteChanged({required this.route}): super._();
|
const BridgeEvent_AudioRouteChanged({required this.route}): super._();
|
||||||
|
|
||||||
|
|
||||||
|
/// New audio output route.
|
||||||
final BridgeAudioRoute route;
|
final BridgeAudioRoute route;
|
||||||
|
|
||||||
/// Create a copy of BridgeEvent
|
/// Create a copy of BridgeEvent
|
||||||
@@ -1150,7 +1154,9 @@ class BridgeEvent_ClientMoved extends BridgeEvent {
|
|||||||
const BridgeEvent_ClientMoved({required this.clientId, required this.newChannelId}): super._();
|
const BridgeEvent_ClientMoved({required this.clientId, required this.newChannelId}): super._();
|
||||||
|
|
||||||
|
|
||||||
|
/// Unique client identifier.
|
||||||
final BigInt clientId;
|
final BigInt clientId;
|
||||||
|
/// Destination channel.
|
||||||
final BigInt newChannelId;
|
final BigInt newChannelId;
|
||||||
|
|
||||||
/// Create a copy of BridgeEvent
|
/// Create a copy of BridgeEvent
|
||||||
@@ -1218,13 +1224,21 @@ class BridgeEvent_ClientJoined extends BridgeEvent {
|
|||||||
const BridgeEvent_ClientJoined({required this.clientId, required this.channelId, required this.name, required this.inputMuted, required this.outputMuted, required this.isServerQuery, required this.talkPower, required this.talkPowerGranted}): super._();
|
const BridgeEvent_ClientJoined({required this.clientId, required this.channelId, required this.name, required this.inputMuted, required this.outputMuted, required this.isServerQuery, required this.talkPower, required this.talkPowerGranted}): super._();
|
||||||
|
|
||||||
|
|
||||||
|
/// Unique client identifier.
|
||||||
final BigInt clientId;
|
final BigInt clientId;
|
||||||
|
/// Channel the client joined.
|
||||||
final BigInt channelId;
|
final BigInt channelId;
|
||||||
|
/// Display nickname.
|
||||||
final String name;
|
final String name;
|
||||||
|
/// Microphone muted state.
|
||||||
final bool inputMuted;
|
final bool inputMuted;
|
||||||
|
/// Speaker muted state.
|
||||||
final bool outputMuted;
|
final bool outputMuted;
|
||||||
|
/// True for server query (bot) clients.
|
||||||
final bool isServerQuery;
|
final bool isServerQuery;
|
||||||
|
/// Client's talk power value.
|
||||||
final int talkPower;
|
final int talkPower;
|
||||||
|
/// Whether the server granted temporary talk power.
|
||||||
final bool talkPowerGranted;
|
final bool talkPowerGranted;
|
||||||
|
|
||||||
/// Create a copy of BridgeEvent
|
/// Create a copy of BridgeEvent
|
||||||
@@ -1298,7 +1312,9 @@ class BridgeEvent_ClientLeft extends BridgeEvent {
|
|||||||
const BridgeEvent_ClientLeft({required this.clientId, required this.name}): super._();
|
const BridgeEvent_ClientLeft({required this.clientId, required this.name}): super._();
|
||||||
|
|
||||||
|
|
||||||
|
/// Unique client identifier.
|
||||||
final BigInt clientId;
|
final BigInt clientId;
|
||||||
|
/// Display nickname at time of disconnect.
|
||||||
final String name;
|
final String name;
|
||||||
|
|
||||||
/// Create a copy of BridgeEvent
|
/// Create a copy of BridgeEvent
|
||||||
@@ -1366,11 +1382,17 @@ class BridgeEvent_ClientUpdated extends BridgeEvent {
|
|||||||
const BridgeEvent_ClientUpdated({required this.clientId, required this.inputMuted, required this.outputMuted, required this.isServerQuery, required this.talkPower, required this.talkPowerGranted}): super._();
|
const BridgeEvent_ClientUpdated({required this.clientId, required this.inputMuted, required this.outputMuted, required this.isServerQuery, required this.talkPower, required this.talkPowerGranted}): super._();
|
||||||
|
|
||||||
|
|
||||||
|
/// Unique client identifier.
|
||||||
final BigInt clientId;
|
final BigInt clientId;
|
||||||
|
/// Microphone muted state.
|
||||||
final bool inputMuted;
|
final bool inputMuted;
|
||||||
|
/// Speaker muted state.
|
||||||
final bool outputMuted;
|
final bool outputMuted;
|
||||||
|
/// True for server query (bot) clients.
|
||||||
final bool isServerQuery;
|
final bool isServerQuery;
|
||||||
|
/// Client's talk power value.
|
||||||
final int talkPower;
|
final int talkPower;
|
||||||
|
/// Whether the server granted temporary talk power.
|
||||||
final bool talkPowerGranted;
|
final bool talkPowerGranted;
|
||||||
|
|
||||||
/// Create a copy of BridgeEvent
|
/// Create a copy of BridgeEvent
|
||||||
@@ -1442,11 +1464,18 @@ class BridgeEvent_ChannelAdded extends BridgeEvent {
|
|||||||
const BridgeEvent_ChannelAdded({required this.id, required this.parent, required this.name, required this.order, required this.hasPassword, this.neededTalkPower}): super._();
|
const BridgeEvent_ChannelAdded({required this.id, required this.parent, required this.name, required this.order, required this.hasPassword, this.neededTalkPower}): super._();
|
||||||
|
|
||||||
|
|
||||||
|
/// Unique channel identifier.
|
||||||
final BigInt id;
|
final BigInt id;
|
||||||
|
/// Parent channel ID.
|
||||||
final BigInt parent;
|
final BigInt parent;
|
||||||
|
/// Channel name.
|
||||||
final String name;
|
final String name;
|
||||||
|
/// Predecessor channel ID within the same parent (TeamSpeak
|
||||||
|
/// linked-list ordering hint). Zero means first child.
|
||||||
final PlatformInt64 order;
|
final PlatformInt64 order;
|
||||||
|
/// Whether the channel requires a password.
|
||||||
final bool hasPassword;
|
final bool hasPassword;
|
||||||
|
/// Talk power required to speak; `None` means no restriction.
|
||||||
final int? neededTalkPower;
|
final int? neededTalkPower;
|
||||||
|
|
||||||
/// Create a copy of BridgeEvent
|
/// Create a copy of BridgeEvent
|
||||||
@@ -1518,6 +1547,7 @@ class BridgeEvent_ChannelRemoved extends BridgeEvent {
|
|||||||
const BridgeEvent_ChannelRemoved({required this.id}): super._();
|
const BridgeEvent_ChannelRemoved({required this.id}): super._();
|
||||||
|
|
||||||
|
|
||||||
|
/// Channel identifier.
|
||||||
final BigInt id;
|
final BigInt id;
|
||||||
|
|
||||||
/// Create a copy of BridgeEvent
|
/// Create a copy of BridgeEvent
|
||||||
@@ -1584,9 +1614,13 @@ class BridgeEvent_ChannelUpdated extends BridgeEvent {
|
|||||||
const BridgeEvent_ChannelUpdated({required this.id, required this.name, required this.hasPassword, this.neededTalkPower}): super._();
|
const BridgeEvent_ChannelUpdated({required this.id, required this.name, required this.hasPassword, this.neededTalkPower}): super._();
|
||||||
|
|
||||||
|
|
||||||
|
/// Unique channel identifier.
|
||||||
final BigInt id;
|
final BigInt id;
|
||||||
|
/// Channel name.
|
||||||
final String name;
|
final String name;
|
||||||
|
/// Whether the channel requires a password.
|
||||||
final bool hasPassword;
|
final bool hasPassword;
|
||||||
|
/// Talk power required to speak; `None` means no restriction.
|
||||||
final int? neededTalkPower;
|
final int? neededTalkPower;
|
||||||
|
|
||||||
/// Create a copy of BridgeEvent
|
/// Create a copy of BridgeEvent
|
||||||
|
|||||||
@@ -1683,6 +1683,12 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
|||||||
return dco_decode_bridge_message_target(raw);
|
return dco_decode_bridge_message_target(raw);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@protected
|
||||||
|
BridgePokeStrength dco_decode_box_autoadd_bridge_poke_strength(dynamic raw) {
|
||||||
|
// Codec=Dco (DartCObject based), see doc to use other codecs
|
||||||
|
return dco_decode_bridge_poke_strength(raw);
|
||||||
|
}
|
||||||
|
|
||||||
@protected
|
@protected
|
||||||
BridgeVoiceJoinErrorCode dco_decode_box_autoadd_bridge_voice_join_error_code(
|
BridgeVoiceJoinErrorCode dco_decode_box_autoadd_bridge_voice_join_error_code(
|
||||||
dynamic raw,
|
dynamic raw,
|
||||||
@@ -2007,6 +2013,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
|||||||
senderName: dco_decode_String(raw[2]),
|
senderName: dco_decode_String(raw[2]),
|
||||||
message: dco_decode_String(raw[3]),
|
message: dco_decode_String(raw[3]),
|
||||||
target: dco_decode_box_autoadd_bridge_message_target(raw[4]),
|
target: dco_decode_box_autoadd_bridge_message_target(raw[4]),
|
||||||
|
pokeStrength: dco_decode_opt_box_autoadd_bridge_poke_strength(raw[5]),
|
||||||
);
|
);
|
||||||
case 11:
|
case 11:
|
||||||
return BridgeEvent_ServerActivity(message: dco_decode_String(raw[1]));
|
return BridgeEvent_ServerActivity(message: dco_decode_String(raw[1]));
|
||||||
@@ -2098,6 +2105,12 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
|||||||
return BridgeNetworkState.values[raw as int];
|
return BridgeNetworkState.values[raw as int];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@protected
|
||||||
|
BridgePokeStrength dco_decode_bridge_poke_strength(dynamic raw) {
|
||||||
|
// Codec=Dco (DartCObject based), see doc to use other codecs
|
||||||
|
return BridgePokeStrength.values[raw as int];
|
||||||
|
}
|
||||||
|
|
||||||
@protected
|
@protected
|
||||||
BridgePttBinding dco_decode_bridge_ptt_binding(dynamic raw) {
|
BridgePttBinding dco_decode_bridge_ptt_binding(dynamic raw) {
|
||||||
// Codec=Dco (DartCObject based), see doc to use other codecs
|
// Codec=Dco (DartCObject based), see doc to use other codecs
|
||||||
@@ -2234,6 +2247,16 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
|||||||
return raw == null ? null : dco_decode_String(raw);
|
return raw == null ? null : dco_decode_String(raw);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@protected
|
||||||
|
BridgePokeStrength? dco_decode_opt_box_autoadd_bridge_poke_strength(
|
||||||
|
dynamic raw,
|
||||||
|
) {
|
||||||
|
// Codec=Dco (DartCObject based), see doc to use other codecs
|
||||||
|
return raw == null
|
||||||
|
? null
|
||||||
|
: dco_decode_box_autoadd_bridge_poke_strength(raw);
|
||||||
|
}
|
||||||
|
|
||||||
@protected
|
@protected
|
||||||
BridgeVoiceJoinErrorCode?
|
BridgeVoiceJoinErrorCode?
|
||||||
dco_decode_opt_box_autoadd_bridge_voice_join_error_code(dynamic raw) {
|
dco_decode_opt_box_autoadd_bridge_voice_join_error_code(dynamic raw) {
|
||||||
@@ -2358,6 +2381,14 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
|||||||
return (sse_decode_bridge_message_target(deserializer));
|
return (sse_decode_bridge_message_target(deserializer));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@protected
|
||||||
|
BridgePokeStrength sse_decode_box_autoadd_bridge_poke_strength(
|
||||||
|
SseDeserializer deserializer,
|
||||||
|
) {
|
||||||
|
// Codec=Sse (Serialization based), see doc to use other codecs
|
||||||
|
return (sse_decode_bridge_poke_strength(deserializer));
|
||||||
|
}
|
||||||
|
|
||||||
@protected
|
@protected
|
||||||
BridgeVoiceJoinErrorCode sse_decode_box_autoadd_bridge_voice_join_error_code(
|
BridgeVoiceJoinErrorCode sse_decode_box_autoadd_bridge_voice_join_error_code(
|
||||||
SseDeserializer deserializer,
|
SseDeserializer deserializer,
|
||||||
@@ -2809,11 +2840,15 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
|||||||
var var_target = sse_decode_box_autoadd_bridge_message_target(
|
var var_target = sse_decode_box_autoadd_bridge_message_target(
|
||||||
deserializer,
|
deserializer,
|
||||||
);
|
);
|
||||||
|
var var_pokeStrength = sse_decode_opt_box_autoadd_bridge_poke_strength(
|
||||||
|
deserializer,
|
||||||
|
);
|
||||||
return BridgeEvent_ChatMessage(
|
return BridgeEvent_ChatMessage(
|
||||||
senderId: var_senderId,
|
senderId: var_senderId,
|
||||||
senderName: var_senderName,
|
senderName: var_senderName,
|
||||||
message: var_message,
|
message: var_message,
|
||||||
target: var_target,
|
target: var_target,
|
||||||
|
pokeStrength: var_pokeStrength,
|
||||||
);
|
);
|
||||||
case 11:
|
case 11:
|
||||||
var var_message = sse_decode_String(deserializer);
|
var var_message = sse_decode_String(deserializer);
|
||||||
@@ -2941,6 +2976,15 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
|||||||
return BridgeNetworkState.values[inner];
|
return BridgeNetworkState.values[inner];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@protected
|
||||||
|
BridgePokeStrength sse_decode_bridge_poke_strength(
|
||||||
|
SseDeserializer deserializer,
|
||||||
|
) {
|
||||||
|
// Codec=Sse (Serialization based), see doc to use other codecs
|
||||||
|
var inner = sse_decode_i_32(deserializer);
|
||||||
|
return BridgePokeStrength.values[inner];
|
||||||
|
}
|
||||||
|
|
||||||
@protected
|
@protected
|
||||||
BridgePttBinding sse_decode_bridge_ptt_binding(SseDeserializer deserializer) {
|
BridgePttBinding sse_decode_bridge_ptt_binding(SseDeserializer deserializer) {
|
||||||
// Codec=Sse (Serialization based), see doc to use other codecs
|
// Codec=Sse (Serialization based), see doc to use other codecs
|
||||||
@@ -3132,6 +3176,19 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@protected
|
||||||
|
BridgePokeStrength? sse_decode_opt_box_autoadd_bridge_poke_strength(
|
||||||
|
SseDeserializer deserializer,
|
||||||
|
) {
|
||||||
|
// Codec=Sse (Serialization based), see doc to use other codecs
|
||||||
|
|
||||||
|
if (sse_decode_bool(deserializer)) {
|
||||||
|
return (sse_decode_box_autoadd_bridge_poke_strength(deserializer));
|
||||||
|
} else {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@protected
|
@protected
|
||||||
BridgeVoiceJoinErrorCode?
|
BridgeVoiceJoinErrorCode?
|
||||||
sse_decode_opt_box_autoadd_bridge_voice_join_error_code(
|
sse_decode_opt_box_autoadd_bridge_voice_join_error_code(
|
||||||
@@ -3306,6 +3363,15 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
|||||||
sse_encode_bridge_message_target(self, serializer);
|
sse_encode_bridge_message_target(self, serializer);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@protected
|
||||||
|
void sse_encode_box_autoadd_bridge_poke_strength(
|
||||||
|
BridgePokeStrength self,
|
||||||
|
SseSerializer serializer,
|
||||||
|
) {
|
||||||
|
// Codec=Sse (Serialization based), see doc to use other codecs
|
||||||
|
sse_encode_bridge_poke_strength(self, serializer);
|
||||||
|
}
|
||||||
|
|
||||||
@protected
|
@protected
|
||||||
void sse_encode_box_autoadd_bridge_voice_join_error_code(
|
void sse_encode_box_autoadd_bridge_voice_join_error_code(
|
||||||
BridgeVoiceJoinErrorCode self,
|
BridgeVoiceJoinErrorCode self,
|
||||||
@@ -3644,12 +3710,17 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
|||||||
senderName: final senderName,
|
senderName: final senderName,
|
||||||
message: final message,
|
message: final message,
|
||||||
target: final target,
|
target: final target,
|
||||||
|
pokeStrength: final pokeStrength,
|
||||||
):
|
):
|
||||||
sse_encode_i_32(10, serializer);
|
sse_encode_i_32(10, serializer);
|
||||||
sse_encode_u_64(senderId, serializer);
|
sse_encode_u_64(senderId, serializer);
|
||||||
sse_encode_String(senderName, serializer);
|
sse_encode_String(senderName, serializer);
|
||||||
sse_encode_String(message, serializer);
|
sse_encode_String(message, serializer);
|
||||||
sse_encode_box_autoadd_bridge_message_target(target, serializer);
|
sse_encode_box_autoadd_bridge_message_target(target, serializer);
|
||||||
|
sse_encode_opt_box_autoadd_bridge_poke_strength(
|
||||||
|
pokeStrength,
|
||||||
|
serializer,
|
||||||
|
);
|
||||||
case BridgeEvent_ServerActivity(message: final message):
|
case BridgeEvent_ServerActivity(message: final message):
|
||||||
sse_encode_i_32(11, serializer);
|
sse_encode_i_32(11, serializer);
|
||||||
sse_encode_String(message, serializer);
|
sse_encode_String(message, serializer);
|
||||||
@@ -3771,6 +3842,15 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
|||||||
sse_encode_i_32(self.index, serializer);
|
sse_encode_i_32(self.index, serializer);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@protected
|
||||||
|
void sse_encode_bridge_poke_strength(
|
||||||
|
BridgePokeStrength self,
|
||||||
|
SseSerializer serializer,
|
||||||
|
) {
|
||||||
|
// Codec=Sse (Serialization based), see doc to use other codecs
|
||||||
|
sse_encode_i_32(self.index, serializer);
|
||||||
|
}
|
||||||
|
|
||||||
@protected
|
@protected
|
||||||
void sse_encode_bridge_ptt_binding(
|
void sse_encode_bridge_ptt_binding(
|
||||||
BridgePttBinding self,
|
BridgePttBinding self,
|
||||||
@@ -3947,6 +4027,19 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@protected
|
||||||
|
void sse_encode_opt_box_autoadd_bridge_poke_strength(
|
||||||
|
BridgePokeStrength? 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_bridge_poke_strength(self, serializer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@protected
|
@protected
|
||||||
void sse_encode_opt_box_autoadd_bridge_voice_join_error_code(
|
void sse_encode_opt_box_autoadd_bridge_voice_join_error_code(
|
||||||
BridgeVoiceJoinErrorCode? self,
|
BridgeVoiceJoinErrorCode? self,
|
||||||
|
|||||||
@@ -46,6 +46,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
|
|||||||
@protected
|
@protected
|
||||||
BridgeMessageTarget dco_decode_box_autoadd_bridge_message_target(dynamic raw);
|
BridgeMessageTarget dco_decode_box_autoadd_bridge_message_target(dynamic raw);
|
||||||
|
|
||||||
|
@protected
|
||||||
|
BridgePokeStrength dco_decode_box_autoadd_bridge_poke_strength(dynamic raw);
|
||||||
|
|
||||||
@protected
|
@protected
|
||||||
BridgeVoiceJoinErrorCode dco_decode_box_autoadd_bridge_voice_join_error_code(
|
BridgeVoiceJoinErrorCode dco_decode_box_autoadd_bridge_voice_join_error_code(
|
||||||
dynamic raw,
|
dynamic raw,
|
||||||
@@ -120,6 +123,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
|
|||||||
@protected
|
@protected
|
||||||
BridgeNetworkState dco_decode_bridge_network_state(dynamic raw);
|
BridgeNetworkState dco_decode_bridge_network_state(dynamic raw);
|
||||||
|
|
||||||
|
@protected
|
||||||
|
BridgePokeStrength dco_decode_bridge_poke_strength(dynamic raw);
|
||||||
|
|
||||||
@protected
|
@protected
|
||||||
BridgePttBinding dco_decode_bridge_ptt_binding(dynamic raw);
|
BridgePttBinding dco_decode_bridge_ptt_binding(dynamic raw);
|
||||||
|
|
||||||
@@ -174,6 +180,11 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
|
|||||||
@protected
|
@protected
|
||||||
String? dco_decode_opt_String(dynamic raw);
|
String? dco_decode_opt_String(dynamic raw);
|
||||||
|
|
||||||
|
@protected
|
||||||
|
BridgePokeStrength? dco_decode_opt_box_autoadd_bridge_poke_strength(
|
||||||
|
dynamic raw,
|
||||||
|
);
|
||||||
|
|
||||||
@protected
|
@protected
|
||||||
BridgeVoiceJoinErrorCode?
|
BridgeVoiceJoinErrorCode?
|
||||||
dco_decode_opt_box_autoadd_bridge_voice_join_error_code(dynamic raw);
|
dco_decode_opt_box_autoadd_bridge_voice_join_error_code(dynamic raw);
|
||||||
@@ -240,6 +251,11 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
|
|||||||
SseDeserializer deserializer,
|
SseDeserializer deserializer,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
@protected
|
||||||
|
BridgePokeStrength sse_decode_box_autoadd_bridge_poke_strength(
|
||||||
|
SseDeserializer deserializer,
|
||||||
|
);
|
||||||
|
|
||||||
@protected
|
@protected
|
||||||
BridgeVoiceJoinErrorCode sse_decode_box_autoadd_bridge_voice_join_error_code(
|
BridgeVoiceJoinErrorCode sse_decode_box_autoadd_bridge_voice_join_error_code(
|
||||||
SseDeserializer deserializer,
|
SseDeserializer deserializer,
|
||||||
@@ -328,6 +344,11 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
|
|||||||
SseDeserializer deserializer,
|
SseDeserializer deserializer,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
@protected
|
||||||
|
BridgePokeStrength sse_decode_bridge_poke_strength(
|
||||||
|
SseDeserializer deserializer,
|
||||||
|
);
|
||||||
|
|
||||||
@protected
|
@protected
|
||||||
BridgePttBinding sse_decode_bridge_ptt_binding(SseDeserializer deserializer);
|
BridgePttBinding sse_decode_bridge_ptt_binding(SseDeserializer deserializer);
|
||||||
|
|
||||||
@@ -400,6 +421,11 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
|
|||||||
@protected
|
@protected
|
||||||
String? sse_decode_opt_String(SseDeserializer deserializer);
|
String? sse_decode_opt_String(SseDeserializer deserializer);
|
||||||
|
|
||||||
|
@protected
|
||||||
|
BridgePokeStrength? sse_decode_opt_box_autoadd_bridge_poke_strength(
|
||||||
|
SseDeserializer deserializer,
|
||||||
|
);
|
||||||
|
|
||||||
@protected
|
@protected
|
||||||
BridgeVoiceJoinErrorCode?
|
BridgeVoiceJoinErrorCode?
|
||||||
sse_decode_opt_box_autoadd_bridge_voice_join_error_code(
|
sse_decode_opt_box_autoadd_bridge_voice_join_error_code(
|
||||||
@@ -477,6 +503,12 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
|
|||||||
SseSerializer serializer,
|
SseSerializer serializer,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
@protected
|
||||||
|
void sse_encode_box_autoadd_bridge_poke_strength(
|
||||||
|
BridgePokeStrength self,
|
||||||
|
SseSerializer serializer,
|
||||||
|
);
|
||||||
|
|
||||||
@protected
|
@protected
|
||||||
void sse_encode_box_autoadd_bridge_voice_join_error_code(
|
void sse_encode_box_autoadd_bridge_voice_join_error_code(
|
||||||
BridgeVoiceJoinErrorCode self,
|
BridgeVoiceJoinErrorCode self,
|
||||||
@@ -588,6 +620,12 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
|
|||||||
SseSerializer serializer,
|
SseSerializer serializer,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
@protected
|
||||||
|
void sse_encode_bridge_poke_strength(
|
||||||
|
BridgePokeStrength self,
|
||||||
|
SseSerializer serializer,
|
||||||
|
);
|
||||||
|
|
||||||
@protected
|
@protected
|
||||||
void sse_encode_bridge_ptt_binding(
|
void sse_encode_bridge_ptt_binding(
|
||||||
BridgePttBinding self,
|
BridgePttBinding self,
|
||||||
@@ -681,6 +719,12 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
|
|||||||
@protected
|
@protected
|
||||||
void sse_encode_opt_String(String? self, SseSerializer serializer);
|
void sse_encode_opt_String(String? self, SseSerializer serializer);
|
||||||
|
|
||||||
|
@protected
|
||||||
|
void sse_encode_opt_box_autoadd_bridge_poke_strength(
|
||||||
|
BridgePokeStrength? self,
|
||||||
|
SseSerializer serializer,
|
||||||
|
);
|
||||||
|
|
||||||
@protected
|
@protected
|
||||||
void sse_encode_opt_box_autoadd_bridge_voice_join_error_code(
|
void sse_encode_opt_box_autoadd_bridge_voice_join_error_code(
|
||||||
BridgeVoiceJoinErrorCode? self,
|
BridgeVoiceJoinErrorCode? self,
|
||||||
|
|||||||
@@ -48,6 +48,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
|
|||||||
@protected
|
@protected
|
||||||
BridgeMessageTarget dco_decode_box_autoadd_bridge_message_target(dynamic raw);
|
BridgeMessageTarget dco_decode_box_autoadd_bridge_message_target(dynamic raw);
|
||||||
|
|
||||||
|
@protected
|
||||||
|
BridgePokeStrength dco_decode_box_autoadd_bridge_poke_strength(dynamic raw);
|
||||||
|
|
||||||
@protected
|
@protected
|
||||||
BridgeVoiceJoinErrorCode dco_decode_box_autoadd_bridge_voice_join_error_code(
|
BridgeVoiceJoinErrorCode dco_decode_box_autoadd_bridge_voice_join_error_code(
|
||||||
dynamic raw,
|
dynamic raw,
|
||||||
@@ -122,6 +125,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
|
|||||||
@protected
|
@protected
|
||||||
BridgeNetworkState dco_decode_bridge_network_state(dynamic raw);
|
BridgeNetworkState dco_decode_bridge_network_state(dynamic raw);
|
||||||
|
|
||||||
|
@protected
|
||||||
|
BridgePokeStrength dco_decode_bridge_poke_strength(dynamic raw);
|
||||||
|
|
||||||
@protected
|
@protected
|
||||||
BridgePttBinding dco_decode_bridge_ptt_binding(dynamic raw);
|
BridgePttBinding dco_decode_bridge_ptt_binding(dynamic raw);
|
||||||
|
|
||||||
@@ -176,6 +182,11 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
|
|||||||
@protected
|
@protected
|
||||||
String? dco_decode_opt_String(dynamic raw);
|
String? dco_decode_opt_String(dynamic raw);
|
||||||
|
|
||||||
|
@protected
|
||||||
|
BridgePokeStrength? dco_decode_opt_box_autoadd_bridge_poke_strength(
|
||||||
|
dynamic raw,
|
||||||
|
);
|
||||||
|
|
||||||
@protected
|
@protected
|
||||||
BridgeVoiceJoinErrorCode?
|
BridgeVoiceJoinErrorCode?
|
||||||
dco_decode_opt_box_autoadd_bridge_voice_join_error_code(dynamic raw);
|
dco_decode_opt_box_autoadd_bridge_voice_join_error_code(dynamic raw);
|
||||||
@@ -242,6 +253,11 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
|
|||||||
SseDeserializer deserializer,
|
SseDeserializer deserializer,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
@protected
|
||||||
|
BridgePokeStrength sse_decode_box_autoadd_bridge_poke_strength(
|
||||||
|
SseDeserializer deserializer,
|
||||||
|
);
|
||||||
|
|
||||||
@protected
|
@protected
|
||||||
BridgeVoiceJoinErrorCode sse_decode_box_autoadd_bridge_voice_join_error_code(
|
BridgeVoiceJoinErrorCode sse_decode_box_autoadd_bridge_voice_join_error_code(
|
||||||
SseDeserializer deserializer,
|
SseDeserializer deserializer,
|
||||||
@@ -330,6 +346,11 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
|
|||||||
SseDeserializer deserializer,
|
SseDeserializer deserializer,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
@protected
|
||||||
|
BridgePokeStrength sse_decode_bridge_poke_strength(
|
||||||
|
SseDeserializer deserializer,
|
||||||
|
);
|
||||||
|
|
||||||
@protected
|
@protected
|
||||||
BridgePttBinding sse_decode_bridge_ptt_binding(SseDeserializer deserializer);
|
BridgePttBinding sse_decode_bridge_ptt_binding(SseDeserializer deserializer);
|
||||||
|
|
||||||
@@ -402,6 +423,11 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
|
|||||||
@protected
|
@protected
|
||||||
String? sse_decode_opt_String(SseDeserializer deserializer);
|
String? sse_decode_opt_String(SseDeserializer deserializer);
|
||||||
|
|
||||||
|
@protected
|
||||||
|
BridgePokeStrength? sse_decode_opt_box_autoadd_bridge_poke_strength(
|
||||||
|
SseDeserializer deserializer,
|
||||||
|
);
|
||||||
|
|
||||||
@protected
|
@protected
|
||||||
BridgeVoiceJoinErrorCode?
|
BridgeVoiceJoinErrorCode?
|
||||||
sse_decode_opt_box_autoadd_bridge_voice_join_error_code(
|
sse_decode_opt_box_autoadd_bridge_voice_join_error_code(
|
||||||
@@ -479,6 +505,12 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
|
|||||||
SseSerializer serializer,
|
SseSerializer serializer,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
@protected
|
||||||
|
void sse_encode_box_autoadd_bridge_poke_strength(
|
||||||
|
BridgePokeStrength self,
|
||||||
|
SseSerializer serializer,
|
||||||
|
);
|
||||||
|
|
||||||
@protected
|
@protected
|
||||||
void sse_encode_box_autoadd_bridge_voice_join_error_code(
|
void sse_encode_box_autoadd_bridge_voice_join_error_code(
|
||||||
BridgeVoiceJoinErrorCode self,
|
BridgeVoiceJoinErrorCode self,
|
||||||
@@ -590,6 +622,12 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
|
|||||||
SseSerializer serializer,
|
SseSerializer serializer,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
@protected
|
||||||
|
void sse_encode_bridge_poke_strength(
|
||||||
|
BridgePokeStrength self,
|
||||||
|
SseSerializer serializer,
|
||||||
|
);
|
||||||
|
|
||||||
@protected
|
@protected
|
||||||
void sse_encode_bridge_ptt_binding(
|
void sse_encode_bridge_ptt_binding(
|
||||||
BridgePttBinding self,
|
BridgePttBinding self,
|
||||||
@@ -683,6 +721,12 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
|
|||||||
@protected
|
@protected
|
||||||
void sse_encode_opt_String(String? self, SseSerializer serializer);
|
void sse_encode_opt_String(String? self, SseSerializer serializer);
|
||||||
|
|
||||||
|
@protected
|
||||||
|
void sse_encode_opt_box_autoadd_bridge_poke_strength(
|
||||||
|
BridgePokeStrength? self,
|
||||||
|
SseSerializer serializer,
|
||||||
|
);
|
||||||
|
|
||||||
@protected
|
@protected
|
||||||
void sse_encode_opt_box_autoadd_bridge_voice_join_error_code(
|
void sse_encode_opt_box_autoadd_bridge_voice_join_error_code(
|
||||||
BridgeVoiceJoinErrorCode? self,
|
BridgeVoiceJoinErrorCode? self,
|
||||||
|
|||||||
@@ -15,6 +15,12 @@ const double _chatSidebarTileExtent = 92;
|
|||||||
const double _chatSidebarCompactTileExtent = 76;
|
const double _chatSidebarCompactTileExtent = 76;
|
||||||
const double _chatSidebarCompactHeight = 84;
|
const double _chatSidebarCompactHeight = 84;
|
||||||
|
|
||||||
|
typedef ChatMessageSender =
|
||||||
|
Future<void> Function({
|
||||||
|
required String message,
|
||||||
|
required rust.BridgeMessageTarget target,
|
||||||
|
});
|
||||||
|
|
||||||
/// One chat/activity message shown in the chat hub.
|
/// One chat/activity message shown in the chat hub.
|
||||||
class ChatEntry {
|
class ChatEntry {
|
||||||
/// Construct a chat entry.
|
/// Construct a chat entry.
|
||||||
@@ -497,7 +503,7 @@ String chatInputPlaceholder(
|
|||||||
case rust.BridgeMessageTarget_Client():
|
case rust.BridgeMessageTarget_Client():
|
||||||
return 'Message $clientName...';
|
return 'Message $clientName...';
|
||||||
case rust.BridgeMessageTarget_Poke():
|
case rust.BridgeMessageTarget_Poke():
|
||||||
return 'Poke message...';
|
return 'Poke message optional...';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -513,6 +519,16 @@ bool canSendToChatTarget(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool canSendChatMessage(
|
||||||
|
rust.BridgeMessageTarget target,
|
||||||
|
BigInt? currentChannelId,
|
||||||
|
String text,
|
||||||
|
) {
|
||||||
|
if (!canSendToChatTarget(target, currentChannelId)) return false;
|
||||||
|
if (target is rust.BridgeMessageTarget_Poke) return true;
|
||||||
|
return text.trim().isNotEmpty;
|
||||||
|
}
|
||||||
|
|
||||||
String? chatSendBlockedReason(
|
String? chatSendBlockedReason(
|
||||||
rust.BridgeMessageTarget target,
|
rust.BridgeMessageTarget target,
|
||||||
BigInt? currentChannelId,
|
BigInt? currentChannelId,
|
||||||
@@ -1066,6 +1082,7 @@ class ChatDetailView extends StatefulWidget {
|
|||||||
this.messageMaxWidth,
|
this.messageMaxWidth,
|
||||||
this.restoredDraft,
|
this.restoredDraft,
|
||||||
this.onDraftChanged,
|
this.onDraftChanged,
|
||||||
|
this.sendChatMessage,
|
||||||
});
|
});
|
||||||
|
|
||||||
/// Chat target displayed by this detail view.
|
/// Chat target displayed by this detail view.
|
||||||
@@ -1101,6 +1118,9 @@ class ChatDetailView extends StatefulWidget {
|
|||||||
/// Called with the current draft text whenever the target changes or the widget is about to be replaced.
|
/// Called with the current draft text whenever the target changes or the widget is about to be replaced.
|
||||||
final ValueChanged<String>? onDraftChanged;
|
final ValueChanged<String>? onDraftChanged;
|
||||||
|
|
||||||
|
/// Sends a chat message. Defaults to the Rust bridge send path.
|
||||||
|
final ChatMessageSender? sendChatMessage;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<ChatDetailView> createState() => _ChatDetailViewState();
|
State<ChatDetailView> createState() => _ChatDetailViewState();
|
||||||
}
|
}
|
||||||
@@ -1168,9 +1188,12 @@ class _ChatDetailViewState extends State<ChatDetailView> {
|
|||||||
|
|
||||||
void _send() {
|
void _send() {
|
||||||
final text = _textCtl.text.trim();
|
final text = _textCtl.text.trim();
|
||||||
if (text.isEmpty || !_canSend) return;
|
if (!canSendChatMessage(widget.target, widget.currentChannelId, text)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
_textCtl.clear();
|
_textCtl.clear();
|
||||||
unawaited(rust.sendChatMessage(message: text, target: widget.target));
|
final sendChatMessage = widget.sendChatMessage ?? rust.sendChatMessage;
|
||||||
|
unawaited(sendChatMessage(message: text, target: widget.target));
|
||||||
final ownId = widget.snapshot.ownClientId;
|
final ownId = widget.snapshot.ownClientId;
|
||||||
setState(() {
|
setState(() {
|
||||||
widget.messages.add(
|
widget.messages.add(
|
||||||
@@ -1223,6 +1246,9 @@ class _ChatDetailViewState extends State<ChatDetailView> {
|
|||||||
channelName: widget.channelName,
|
channelName: widget.channelName,
|
||||||
clientName: widget.clientName,
|
clientName: widget.clientName,
|
||||||
);
|
);
|
||||||
|
final sendTooltip = widget.target is rust.BridgeMessageTarget_Poke
|
||||||
|
? 'Poke'
|
||||||
|
: 'Send';
|
||||||
|
|
||||||
return Column(
|
return Column(
|
||||||
children: [
|
children: [
|
||||||
@@ -1332,7 +1358,7 @@ class _ChatDetailViewState extends State<ChatDetailView> {
|
|||||||
IconButton.filled(
|
IconButton.filled(
|
||||||
icon: const Icon(Icons.send),
|
icon: const Icon(Icons.send),
|
||||||
onPressed: _send,
|
onPressed: _send,
|
||||||
tooltip: 'Send',
|
tooltip: sendTooltip,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import '../l10n/generated/app_localizations.dart';
|
||||||
|
import '../services/poke_preferences_service.dart';
|
||||||
|
import 'voice_settings_controls.dart';
|
||||||
|
|
||||||
|
class PokeNotificationSettingsDialog extends StatelessWidget {
|
||||||
|
const PokeNotificationSettingsDialog({super.key, required this.preferences});
|
||||||
|
|
||||||
|
final PokePreferencesService preferences;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final l10n = AppL10n.of(context);
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
return AlertDialog(
|
||||||
|
title: Text(l10n.pokeSettingsTitle),
|
||||||
|
contentPadding: const EdgeInsets.fromLTRB(24, 16, 24, 0),
|
||||||
|
content: SizedBox(
|
||||||
|
width: 400,
|
||||||
|
child: SingleChildScrollView(
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
ValueListenableBuilder<bool>(
|
||||||
|
valueListenable: preferences.pokesEnabled,
|
||||||
|
builder: (context, enabled, _) => SwitchListTile(
|
||||||
|
dense: true,
|
||||||
|
contentPadding: EdgeInsets.zero,
|
||||||
|
title: Text(l10n.pokeSettingsEnableLabel),
|
||||||
|
subtitle: Text(l10n.pokeSettingsEnableDescription),
|
||||||
|
value: enabled,
|
||||||
|
onChanged: (value) => preferences.setPokesEnabled(value),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Divider(height: 24),
|
||||||
|
VoiceSectionHeader(l10n.pokeSettingsMutedSendersHeader),
|
||||||
|
ValueListenableBuilder<Set<BigInt>>(
|
||||||
|
valueListenable: preferences.mutedSenders,
|
||||||
|
builder: (context, mutedSenders, _) {
|
||||||
|
final senders = mutedSenders.toList()..sort();
|
||||||
|
if (senders.isEmpty) {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 8, bottom: 8),
|
||||||
|
child: Text(
|
||||||
|
l10n.pokeSettingsMutedSendersEmpty,
|
||||||
|
style: theme.textTheme.bodyMedium?.copyWith(
|
||||||
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
for (final senderId in senders)
|
||||||
|
ListTile(
|
||||||
|
dense: true,
|
||||||
|
contentPadding: EdgeInsets.zero,
|
||||||
|
leading: const Icon(Icons.notifications_off_outlined),
|
||||||
|
title: Text(
|
||||||
|
l10n.pokeSettingsMutedSenderLabel(
|
||||||
|
senderId.toString(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
trailing: TextButton(
|
||||||
|
onPressed: () => preferences.unmuteSender(senderId),
|
||||||
|
child: Text(l10n.pokeSettingsUnmuteSenderAction),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.of(context).pop(),
|
||||||
|
child: Text(l10n.closeAction),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -318,6 +318,15 @@ class _VoicePttButtonState extends State<VoicePttButton> {
|
|||||||
playVoicePttHaptic(held);
|
playVoicePttHaptic(held);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
if (_pressed) {
|
||||||
|
_pressed = false;
|
||||||
|
widget.onHeldChanged(false);
|
||||||
|
}
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final theme = Theme.of(context);
|
final theme = Theme.of(context);
|
||||||
|
|||||||
@@ -23,7 +23,7 @@
|
|||||||
<key>CFBundleVersion</key>
|
<key>CFBundleVersion</key>
|
||||||
<string>$(FLUTTER_BUILD_NUMBER)</string>
|
<string>$(FLUTTER_BUILD_NUMBER)</string>
|
||||||
<key>ITSAppUsesNonExemptEncryption</key>
|
<key>ITSAppUsesNonExemptEncryption</key>
|
||||||
<false/>
|
<true/>
|
||||||
<key>LSMinimumSystemVersion</key>
|
<key>LSMinimumSystemVersion</key>
|
||||||
<string>$(MACOSX_DEPLOYMENT_TARGET)</string>
|
<string>$(MACOSX_DEPLOYMENT_TARGET)</string>
|
||||||
<key>NSHumanReadableCopyright</key>
|
<key>NSHumanReadableCopyright</key>
|
||||||
@@ -42,6 +42,8 @@
|
|||||||
<string>Chanora uses Input Monitoring so push-to-talk keys work even when other apps are focused. Chanora never records what you type — only the key you bound for talking.</string>
|
<string>Chanora uses Input Monitoring so push-to-talk keys work even when other apps are focused. Chanora never records what you type — only the key you bound for talking.</string>
|
||||||
<key>NSLocalNetworkUsageDescription</key>
|
<key>NSLocalNetworkUsageDescription</key>
|
||||||
<string>Chanora needs local network access to connect to TeamSpeak-compatible voice servers.</string>
|
<string>Chanora needs local network access to connect to TeamSpeak-compatible voice servers.</string>
|
||||||
|
<key>NSUserNotificationsUsageDescription</key>
|
||||||
|
<string>Chanora sends you a notification when another user pokes you.</string>
|
||||||
<key>NSBonjourServices</key>
|
<key>NSBonjourServices</key>
|
||||||
<array>
|
<array>
|
||||||
<string>_ts3._tcp</string>
|
<string>_ts3._tcp</string>
|
||||||
|
|||||||
@@ -262,6 +262,46 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "6.0.0"
|
version: "6.0.0"
|
||||||
|
flutter_local_notifications:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: flutter_local_notifications
|
||||||
|
sha256: be38e3854d2baabcda8e16966a5fe8748cebb655bb94701494da0f052c2fc352
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "22.0.0"
|
||||||
|
flutter_local_notifications_linux:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: flutter_local_notifications_linux
|
||||||
|
sha256: "9ca97e63776f29ab1b955725c09999fc2c150523269db150c39274f2a43c5a8b"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "8.0.1"
|
||||||
|
flutter_local_notifications_platform_interface:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: flutter_local_notifications_platform_interface
|
||||||
|
sha256: ff0013eae795e8dc8fad4a8992a209e64d3ba2fbd8bf5e43c36bf448f95bd814
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "12.0.0"
|
||||||
|
flutter_local_notifications_web:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: flutter_local_notifications_web
|
||||||
|
sha256: "516afaf97a2d1e67a036c6617321b00d205d72f7a67b6eccf936cd565f985878"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.0.0"
|
||||||
|
flutter_local_notifications_windows:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: flutter_local_notifications_windows
|
||||||
|
sha256: "5aeed973a0c1480706784fad05c5c3a911335ebb561b2274b47fe80b375201e1"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "3.1.0"
|
||||||
flutter_localizations:
|
flutter_localizations:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description: flutter
|
description: flutter
|
||||||
@@ -794,6 +834,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.7.11"
|
version: "0.7.11"
|
||||||
|
timezone:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: timezone
|
||||||
|
sha256: "784a5e34d2eb62e1326f24d6f600aaaee452eb8ca8ef2f384a59244e292d158b"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.11.0"
|
||||||
typed_data:
|
typed_data:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|||||||
@@ -75,6 +75,7 @@ dependencies:
|
|||||||
# DEC-003 iOS 13 floor; haptic_kit supports iOS 12+).
|
# DEC-003 iOS 13 floor; haptic_kit supports iOS 12+).
|
||||||
haptic_kit: ^1.0.0
|
haptic_kit: ^1.0.0
|
||||||
flutter_foreground_task: ^9.2.2
|
flutter_foreground_task: ^9.2.2
|
||||||
|
flutter_local_notifications: ^22.0.0
|
||||||
url_launcher: ^6.3.2
|
url_launcher: ^6.3.2
|
||||||
shared_preferences: ^2.5.5
|
shared_preferences: ^2.5.5
|
||||||
share_plus: ^13.1.0
|
share_plus: ^13.1.0
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
|
||||||
|
import 'package:chanora_flutter/services/hard_mute_owners.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
group('HardMuteOwners', () {
|
||||||
|
test('manual mute survives talk-power block and restore', () {
|
||||||
|
const owners = HardMuteOwners(manual: true);
|
||||||
|
|
||||||
|
final blocked = owners.copyWith(talkPower: true);
|
||||||
|
expect(blocked.effective, isTrue);
|
||||||
|
|
||||||
|
final restored = blocked.copyWith(talkPower: false);
|
||||||
|
expect(restored.manual, isTrue);
|
||||||
|
expect(restored.talkPower, isFalse);
|
||||||
|
expect(restored.effective, isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('effective mute is the union of independent owners', () {
|
||||||
|
expect(const HardMuteOwners().effective, isFalse);
|
||||||
|
expect(const HardMuteOwners(manual: true).effective, isTrue);
|
||||||
|
expect(const HardMuteOwners(permission: true).effective, isTrue);
|
||||||
|
expect(const HardMuteOwners(talkPower: true).effective, isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('bridge mute does not convert talk-power owner into manual owner', () {
|
||||||
|
const owners = HardMuteOwners(talkPower: true);
|
||||||
|
|
||||||
|
final synced = owners.withBridgeManualMute(true);
|
||||||
|
|
||||||
|
expect(synced.manual, isFalse);
|
||||||
|
expect(synced.talkPower, isTrue);
|
||||||
|
expect(synced.effective, isTrue);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
import 'package:flutter/services.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
|
||||||
|
import 'package:chanora_flutter/services/ios_audio_session_controller.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
TestWidgetsFlutterBinding.ensureInitialized();
|
||||||
|
|
||||||
|
group('IosAudioSessionController', () {
|
||||||
|
const channel = MethodChannel(iosAudioSessionChannelName);
|
||||||
|
final messenger = TestDefaultBinaryMessengerBinding
|
||||||
|
.instance.defaultBinaryMessenger;
|
||||||
|
|
||||||
|
tearDown(() {
|
||||||
|
messenger.setMockMethodCallHandler(channel, null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('channel name matches Swift contract', () {
|
||||||
|
expect(iosAudioSessionChannelName, 'chanora/ios_audio_session');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('activate invokes activateVoiceSession on iOS', () async {
|
||||||
|
final calls = <MethodCall>[];
|
||||||
|
messenger.setMockMethodCallHandler(channel, (call) async {
|
||||||
|
calls.add(call);
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
|
||||||
|
final controller = IosAudioSessionController(
|
||||||
|
channel: channel,
|
||||||
|
isIos: true,
|
||||||
|
);
|
||||||
|
await controller.activate();
|
||||||
|
|
||||||
|
expect(calls.map((c) => c.method), ['activateVoiceSession']);
|
||||||
|
expect(calls.single.arguments, isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('deactivate invokes deactivateVoiceSession on iOS', () async {
|
||||||
|
final calls = <MethodCall>[];
|
||||||
|
messenger.setMockMethodCallHandler(channel, (call) async {
|
||||||
|
calls.add(call);
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
|
||||||
|
final controller = IosAudioSessionController(
|
||||||
|
channel: channel,
|
||||||
|
isIos: true,
|
||||||
|
);
|
||||||
|
await controller.deactivate();
|
||||||
|
|
||||||
|
expect(calls.map((c) => c.method), ['deactivateVoiceSession']);
|
||||||
|
expect(calls.single.arguments, isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('activate is a no-op on non-iOS platforms', () async {
|
||||||
|
var invoked = false;
|
||||||
|
messenger.setMockMethodCallHandler(channel, (call) async {
|
||||||
|
invoked = true;
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
|
||||||
|
final controller = IosAudioSessionController(
|
||||||
|
channel: channel,
|
||||||
|
isIos: false,
|
||||||
|
);
|
||||||
|
await controller.activate();
|
||||||
|
await controller.deactivate();
|
||||||
|
|
||||||
|
expect(invoked, isFalse);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('activate swallows PlatformException so engine keeps running',
|
||||||
|
() async {
|
||||||
|
messenger.setMockMethodCallHandler(channel, (call) async {
|
||||||
|
throw PlatformException(code: 'avaudiosession_failed');
|
||||||
|
});
|
||||||
|
|
||||||
|
final controller = IosAudioSessionController(
|
||||||
|
channel: channel,
|
||||||
|
isIos: true,
|
||||||
|
);
|
||||||
|
|
||||||
|
await expectLater(controller.activate(), completes);
|
||||||
|
await expectLater(controller.deactivate(), completes);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('activate swallows MissingPluginException when channel is absent',
|
||||||
|
() async {
|
||||||
|
final controller = IosAudioSessionController(
|
||||||
|
channel: channel,
|
||||||
|
isIos: true,
|
||||||
|
);
|
||||||
|
|
||||||
|
await expectLater(controller.activate(), completes);
|
||||||
|
await expectLater(controller.deactivate(), completes);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
|
||||||
|
import 'package:chanora_flutter/main.dart';
|
||||||
|
import 'package:chanora_flutter/src/rust/api.dart' as rust;
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
test('active poke chat suppresses same sender notification only', () {
|
||||||
|
final sender = BigInt.from(42);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
isPokeSenderActiveChat(
|
||||||
|
chatOpen: true,
|
||||||
|
inlineChatTarget: rust.BridgeMessageTarget.poke(sender),
|
||||||
|
senderId: sender,
|
||||||
|
),
|
||||||
|
isTrue,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
isPokeSenderActiveChat(
|
||||||
|
chatOpen: true,
|
||||||
|
inlineChatTarget: rust.BridgeMessageTarget.poke(BigInt.from(7)),
|
||||||
|
senderId: sender,
|
||||||
|
),
|
||||||
|
isFalse,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('active private chat also suppresses same sender poke notification', () {
|
||||||
|
final sender = BigInt.from(42);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
isPokeSenderActiveChat(
|
||||||
|
chatOpen: true,
|
||||||
|
inlineChatTarget: rust.BridgeMessageTarget.client(sender),
|
||||||
|
senderId: sender,
|
||||||
|
),
|
||||||
|
isTrue,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
isPokeSenderActiveChat(
|
||||||
|
chatOpen: false,
|
||||||
|
inlineChatTarget: rust.BridgeMessageTarget.client(sender),
|
||||||
|
senderId: sender,
|
||||||
|
),
|
||||||
|
isFalse,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
|
||||||
|
|
||||||
|
import 'package:chanora_flutter/services/poke_notification_service.dart';
|
||||||
|
import 'package:chanora_flutter/src/rust/api.dart' as rust;
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
TestWidgetsFlutterBinding.ensureInitialized();
|
||||||
|
|
||||||
|
const channel = MethodChannel('dexterous.com/flutter/local_notifications');
|
||||||
|
late List<MethodCall> calls;
|
||||||
|
|
||||||
|
setUp(() {
|
||||||
|
debugDefaultTargetPlatformOverride = TargetPlatform.android;
|
||||||
|
AndroidFlutterLocalNotificationsPlugin.registerWith();
|
||||||
|
calls = <MethodCall>[];
|
||||||
|
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||||
|
.setMockMethodCallHandler(channel, (call) async {
|
||||||
|
calls.add(call);
|
||||||
|
return switch (call.method) {
|
||||||
|
'initialize' => true,
|
||||||
|
'requestNotificationsPermission' => true,
|
||||||
|
_ => null,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
tearDown(() {
|
||||||
|
debugDefaultTargetPlatformOverride = null;
|
||||||
|
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||||
|
.setMockMethodCallHandler(channel, null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test(
|
||||||
|
'show dispatches a silent poke notification with sender payload',
|
||||||
|
() async {
|
||||||
|
final service = PokeNotificationService();
|
||||||
|
|
||||||
|
await service.show(
|
||||||
|
senderName: 'Alice',
|
||||||
|
message: 'wake up',
|
||||||
|
senderId: BigInt.from(42),
|
||||||
|
strength: rust.BridgePokeStrength.strong,
|
||||||
|
);
|
||||||
|
|
||||||
|
final showCall = calls.singleWhere((call) => call.method == 'show');
|
||||||
|
final arguments = Map<Object?, Object?>.from(showCall.arguments as Map);
|
||||||
|
|
||||||
|
expect(arguments['id'], 42);
|
||||||
|
expect(arguments['title'], 'Poke from Alice');
|
||||||
|
expect(arguments['body'], 'wake up');
|
||||||
|
expect(arguments['payload'], 'poke:42');
|
||||||
|
final specifics = Map<Object?, Object?>.from(
|
||||||
|
arguments['platformSpecifics'] as Map,
|
||||||
|
);
|
||||||
|
expect(specifics['silent'], true);
|
||||||
|
expect(specifics['playSound'], false);
|
||||||
|
expect(specifics['groupKey'], 'chanora.pokes');
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test('show uses fallback body for empty poke messages', () async {
|
||||||
|
final service = PokeNotificationService();
|
||||||
|
|
||||||
|
await service.show(
|
||||||
|
senderName: 'Alice',
|
||||||
|
message: ' ',
|
||||||
|
senderId: BigInt.from(42),
|
||||||
|
strength: rust.BridgePokeStrength.strong,
|
||||||
|
);
|
||||||
|
|
||||||
|
final showCall = calls.singleWhere((call) => call.method == 'show');
|
||||||
|
final arguments = Map<Object?, Object?>.from(showCall.arguments as Map);
|
||||||
|
|
||||||
|
expect(arguments['title'], 'Poke from Alice');
|
||||||
|
expect(arguments['body'], 'Alice pokes you');
|
||||||
|
expect(arguments['payload'], 'poke:42');
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
|
import 'package:chanora_flutter/services/poke_preferences_service.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
late PokePreferencesService service;
|
||||||
|
|
||||||
|
setUp(() {
|
||||||
|
SharedPreferences.setMockInitialValues({});
|
||||||
|
service = PokePreferencesService();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('loads defaults when preferences are unset', () async {
|
||||||
|
await service.load();
|
||||||
|
|
||||||
|
expect(service.pokesEnabled.value, isTrue);
|
||||||
|
expect(service.mutedSenders.value, isEmpty);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('persists global enabled state', () async {
|
||||||
|
await service.load();
|
||||||
|
await service.setPokesEnabled(false);
|
||||||
|
|
||||||
|
final reloaded = PokePreferencesService();
|
||||||
|
await reloaded.load();
|
||||||
|
|
||||||
|
expect(reloaded.pokesEnabled.value, isFalse);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('persists muted senders and removes them on unmute', () async {
|
||||||
|
await service.load();
|
||||||
|
final alice = BigInt.from(42);
|
||||||
|
final bob = BigInt.from(7);
|
||||||
|
|
||||||
|
await service.muteSender(alice);
|
||||||
|
await service.muteSender(bob);
|
||||||
|
await service.unmuteSender(alice);
|
||||||
|
|
||||||
|
final reloaded = PokePreferencesService();
|
||||||
|
await reloaded.load();
|
||||||
|
|
||||||
|
expect(reloaded.mutedSenders.value, {bob});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('isMuted reflects in-memory changes synchronously', () async {
|
||||||
|
await service.load();
|
||||||
|
final sender = BigInt.from(99);
|
||||||
|
|
||||||
|
expect(service.isMuted(sender), isFalse);
|
||||||
|
|
||||||
|
await service.muteSender(sender);
|
||||||
|
expect(service.isMuted(sender), isTrue);
|
||||||
|
|
||||||
|
await service.unmuteSender(sender);
|
||||||
|
expect(service.isMuted(sender), isFalse);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -455,7 +455,7 @@ void main() {
|
|||||||
channelName: '',
|
channelName: '',
|
||||||
clientName: 'Alpha',
|
clientName: 'Alpha',
|
||||||
),
|
),
|
||||||
'Poke message...',
|
'Poke message optional...',
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -737,6 +737,179 @@ void main() {
|
|||||||
refresh.dispose();
|
refresh.dispose();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('evaluates target-aware chat message send policy', () {
|
||||||
|
final clientTarget = rust.BridgeMessageTarget.client(BigInt.from(2));
|
||||||
|
final pokeTarget = rust.BridgeMessageTarget.poke(BigInt.from(2));
|
||||||
|
|
||||||
|
expect(canSendChatMessage(pokeTarget, null, ''), isTrue);
|
||||||
|
expect(canSendChatMessage(pokeTarget, null, ' '), isTrue);
|
||||||
|
expect(canSendChatMessage(pokeTarget, null, 'wake up'), isTrue);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
canSendChatMessage(const rust.BridgeMessageTarget.server(), null, ''),
|
||||||
|
isFalse,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
canSendChatMessage(
|
||||||
|
const rust.BridgeMessageTarget.channel(),
|
||||||
|
BigInt.from(10),
|
||||||
|
'',
|
||||||
|
),
|
||||||
|
isFalse,
|
||||||
|
);
|
||||||
|
expect(canSendChatMessage(clientTarget, null, ''), isFalse);
|
||||||
|
expect(
|
||||||
|
canSendChatMessage(
|
||||||
|
const rust.BridgeMessageTarget.channel(),
|
||||||
|
null,
|
||||||
|
'hello',
|
||||||
|
),
|
||||||
|
isFalse,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
canSendChatMessage(
|
||||||
|
const rust.BridgeMessageTarget.channel(),
|
||||||
|
BigInt.from(10),
|
||||||
|
'hello',
|
||||||
|
),
|
||||||
|
isTrue,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('poke detail sends an empty poke when the composer is empty', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
String? sentMessage;
|
||||||
|
rust.BridgeMessageTarget? sentTarget;
|
||||||
|
final messages = <ChatEntry>[];
|
||||||
|
final target = rust.BridgeMessageTarget.poke(BigInt.from(2));
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
MaterialApp(
|
||||||
|
localizationsDelegates: AppL10n.localizationsDelegates,
|
||||||
|
supportedLocales: AppL10n.supportedLocales,
|
||||||
|
home: Scaffold(
|
||||||
|
body: ChatDetailView(
|
||||||
|
messages: messages,
|
||||||
|
snapshot: snapshot(
|
||||||
|
channels: const [],
|
||||||
|
clients: [
|
||||||
|
client(id: BigInt.one, name: 'Me', channelId: BigInt.zero),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
target: target,
|
||||||
|
clientName: 'Alpha',
|
||||||
|
currentChannelId: null,
|
||||||
|
channelName: '',
|
||||||
|
sendChatMessage: ({required message, required target}) async {
|
||||||
|
sentMessage = message;
|
||||||
|
sentTarget = target;
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(find.byTooltip('Poke'), findsOneWidget);
|
||||||
|
expect(find.byTooltip('Send'), findsNothing);
|
||||||
|
|
||||||
|
await tester.tap(find.byTooltip('Poke'));
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
expect(sentMessage, '');
|
||||||
|
expect(sentTarget, target);
|
||||||
|
expect(messages, hasLength(1));
|
||||||
|
expect(messages.single.isPoke, isTrue);
|
||||||
|
expect(messages.single.message, '');
|
||||||
|
expect(find.textContaining('You poked "Alpha"'), findsOneWidget);
|
||||||
|
expect(find.byType(CircleAvatar), findsNothing);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('poke detail sends typed optional poke message', (tester) async {
|
||||||
|
String? sentMessage;
|
||||||
|
rust.BridgeMessageTarget? sentTarget;
|
||||||
|
final messages = <ChatEntry>[];
|
||||||
|
final target = rust.BridgeMessageTarget.poke(BigInt.from(2));
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
MaterialApp(
|
||||||
|
localizationsDelegates: AppL10n.localizationsDelegates,
|
||||||
|
supportedLocales: AppL10n.supportedLocales,
|
||||||
|
home: Scaffold(
|
||||||
|
body: ChatDetailView(
|
||||||
|
messages: messages,
|
||||||
|
snapshot: snapshot(
|
||||||
|
channels: const [],
|
||||||
|
clients: [
|
||||||
|
client(id: BigInt.one, name: 'Me', channelId: BigInt.zero),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
target: target,
|
||||||
|
clientName: 'Alpha',
|
||||||
|
currentChannelId: null,
|
||||||
|
channelName: '',
|
||||||
|
sendChatMessage: ({required message, required target}) async {
|
||||||
|
sentMessage = message;
|
||||||
|
sentTarget = target;
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
await tester.enterText(find.byType(TextField), 'wake up');
|
||||||
|
await tester.tap(find.byTooltip('Poke'));
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
expect(sentMessage, 'wake up');
|
||||||
|
expect(sentTarget, target);
|
||||||
|
expect(messages.single.message, 'wake up');
|
||||||
|
expect(
|
||||||
|
find.textContaining('You poked "Alpha" with message: wake up'),
|
||||||
|
findsOneWidget,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('channel detail blocks empty sends with a joined channel', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
var sendCount = 0;
|
||||||
|
final messages = <ChatEntry>[];
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
MaterialApp(
|
||||||
|
localizationsDelegates: AppL10n.localizationsDelegates,
|
||||||
|
supportedLocales: AppL10n.supportedLocales,
|
||||||
|
home: Scaffold(
|
||||||
|
body: ChatDetailView(
|
||||||
|
messages: messages,
|
||||||
|
snapshot: snapshot(
|
||||||
|
channels: [channel(BigInt.from(10), 'Lobby')],
|
||||||
|
clients: [
|
||||||
|
client(id: BigInt.one, name: 'Me', channelId: BigInt.from(10)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
target: const rust.BridgeMessageTarget.channel(),
|
||||||
|
clientName: '',
|
||||||
|
currentChannelId: BigInt.from(10),
|
||||||
|
channelName: 'Lobby',
|
||||||
|
sendChatMessage: ({required message, required target}) async {
|
||||||
|
sendCount++;
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(find.byTooltip('Send'), findsOneWidget);
|
||||||
|
|
||||||
|
await tester.tap(find.byTooltip('Send'));
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
expect(sendCount, 0);
|
||||||
|
expect(messages, isEmpty);
|
||||||
|
});
|
||||||
|
|
||||||
test('blocks channel chat when no voice channel is joined', () {
|
test('blocks channel chat when no voice channel is joined', () {
|
||||||
expect(
|
expect(
|
||||||
canSendToChatTarget(const rust.BridgeMessageTarget.channel(), null),
|
canSendToChatTarget(const rust.BridgeMessageTarget.channel(), null),
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
|
import 'package:chanora_flutter/l10n/generated/app_localizations.dart';
|
||||||
|
import 'package:chanora_flutter/services/poke_preferences_service.dart';
|
||||||
|
import 'package:chanora_flutter/widgets/poke_notification_settings.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
testWidgets('toggles poke notifications and unmutes senders', (tester) async {
|
||||||
|
SharedPreferences.setMockInitialValues({});
|
||||||
|
final preferences = PokePreferencesService();
|
||||||
|
await preferences.load();
|
||||||
|
await preferences.muteSender(BigInt.from(42));
|
||||||
|
addTearDown(preferences.dispose);
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
MaterialApp(
|
||||||
|
localizationsDelegates: AppL10n.localizationsDelegates,
|
||||||
|
supportedLocales: AppL10n.supportedLocales,
|
||||||
|
home: PokeNotificationSettingsDialog(preferences: preferences),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(find.text('Poke notifications'), findsOneWidget);
|
||||||
|
expect(find.text('Client ID 42'), findsOneWidget);
|
||||||
|
|
||||||
|
await tester.tap(find.byType(Switch));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(preferences.pokesEnabled.value, isFalse);
|
||||||
|
|
||||||
|
await tester.tap(find.text('Unmute'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(preferences.isMuted(BigInt.from(42)), isFalse);
|
||||||
|
expect(find.text('No muted poke senders.'), findsOneWidget);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
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/voice_compact.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
testWidgets('touch PTT releases when disposed while held', (tester) async {
|
||||||
|
final heldChanges = <bool>[];
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
MaterialApp(
|
||||||
|
localizationsDelegates: AppL10n.localizationsDelegates,
|
||||||
|
supportedLocales: AppL10n.supportedLocales,
|
||||||
|
home: Scaffold(
|
||||||
|
body: VoicePttButton(
|
||||||
|
active: false,
|
||||||
|
onHeldChanged: heldChanges.add,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
final center = tester.getCenter(find.byType(VoicePttButton));
|
||||||
|
final gesture = await tester.startGesture(center);
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
expect(heldChanges, [true]);
|
||||||
|
|
||||||
|
await tester.pumpWidget(const MaterialApp(home: Scaffold()));
|
||||||
|
|
||||||
|
expect(heldChanges, [true, false]);
|
||||||
|
|
||||||
|
await gesture.cancel();
|
||||||
|
|
||||||
|
expect(heldChanges, [true, false]);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ list(APPEND FLUTTER_PLUGIN_LIST
|
|||||||
)
|
)
|
||||||
|
|
||||||
list(APPEND FLUTTER_FFI_PLUGIN_LIST
|
list(APPEND FLUTTER_FFI_PLUGIN_LIST
|
||||||
|
flutter_local_notifications_windows
|
||||||
jni
|
jni
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ chanora_diagnostics = { path = "../../crates/chanora_diagnostics" }
|
|||||||
chanora_prefetch = { path = "../../crates/chanora_prefetch" }
|
chanora_prefetch = { path = "../../crates/chanora_prefetch" }
|
||||||
thiserror.workspace = true
|
thiserror.workspace = true
|
||||||
tracing.workspace = true
|
tracing.workspace = true
|
||||||
tokio = { version = "1", features = ["sync", "rt", "macros"] }
|
tokio = { version = "1", features = ["sync", "rt", "macros", "time"] }
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
# Used by integration tests to inspect the bookmark DB row layout
|
# Used by integration tests to inspect the bookmark DB row layout
|
||||||
|
|||||||
@@ -0,0 +1,287 @@
|
|||||||
|
use chanora_audio::{AudioRoute, PttBackendDescriptor};
|
||||||
|
use chanora_protocol::{MessageTarget, PokeStrength};
|
||||||
|
|
||||||
|
/// Privacy-safe snapshot of the active PTT capability.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct PttDescriptorSnapshot {
|
||||||
|
/// Stable capability level name.
|
||||||
|
pub level: String,
|
||||||
|
/// Stable backend identifier.
|
||||||
|
pub backend_id: String,
|
||||||
|
/// Coarse bound input class; empty when no binding is active.
|
||||||
|
pub bound_input_class: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<PttBackendDescriptor> for PttDescriptorSnapshot {
|
||||||
|
fn from(desc: PttBackendDescriptor) -> Self {
|
||||||
|
Self {
|
||||||
|
level: desc.level.as_str().to_string(),
|
||||||
|
backend_id: desc.backend_id.to_string(),
|
||||||
|
bound_input_class: desc.bound_input_class.unwrap_or("").to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Persisted PTT binding state exposed to callers.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct PersistedPttBinding {
|
||||||
|
/// Stable input category string (`""`, `"keyboard"`, or
|
||||||
|
/// `"mouse-side-button"`).
|
||||||
|
pub input_class: String,
|
||||||
|
/// Display-only key label; empty when no binding is active.
|
||||||
|
pub key_label: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PersistedPttBinding {
|
||||||
|
pub(crate) fn empty() -> Self {
|
||||||
|
Self {
|
||||||
|
input_class: String::new(),
|
||||||
|
key_label: String::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// High-level lifecycle event surfaced to subscribers.
|
||||||
|
///
|
||||||
|
/// This is the minimal set needed for A.6 (reconnect banner). The
|
||||||
|
/// full event catalogue lands in A.4.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub enum SessionEvent {
|
||||||
|
/// Initial connect succeeded, or reconnect attempt succeeded.
|
||||||
|
Connected {
|
||||||
|
/// Server name reported in the snapshot.
|
||||||
|
server_name: String,
|
||||||
|
},
|
||||||
|
/// Connection lost; the supervisor will retry.
|
||||||
|
Lost {
|
||||||
|
/// Reason classification from the protocol layer.
|
||||||
|
reason: String,
|
||||||
|
},
|
||||||
|
/// Supervisor is sleeping before its next reconnect attempt.
|
||||||
|
Reconnecting {
|
||||||
|
/// 1-based attempt counter for the current outage.
|
||||||
|
attempt: u32,
|
||||||
|
/// Seconds the supervisor will sleep before this attempt.
|
||||||
|
delay_secs: u32,
|
||||||
|
},
|
||||||
|
/// Supervisor gave up after `attempt` failed retries (or the
|
||||||
|
/// user explicitly disconnected mid-outage).
|
||||||
|
Disconnected {
|
||||||
|
/// Reason classification from the protocol layer.
|
||||||
|
reason: String,
|
||||||
|
},
|
||||||
|
/// Audio engine started (e.g. after a successful reconnect with
|
||||||
|
/// reattachment).
|
||||||
|
AudioStarted,
|
||||||
|
/// Audio engine stopped (e.g. before a reconnect cycle, or by
|
||||||
|
/// explicit user action).
|
||||||
|
AudioStopped,
|
||||||
|
/// Detected desktop Push-to-Talk capability (gen2 v0.9.3 /
|
||||||
|
/// DEC-023..028). Published when the audio engine starts or
|
||||||
|
/// when the active backend transitions (for example macOS
|
||||||
|
/// permission state change). Carries only the privacy-safe
|
||||||
|
/// descriptor — capability level, backend identifier, bound
|
||||||
|
/// input class — per SRS-202 / DEC-027.
|
||||||
|
PttCapability {
|
||||||
|
/// Stable level name from `PttCapabilityLevel::as_str()`.
|
||||||
|
level: String,
|
||||||
|
/// Stable backend identifier (e.g. `"focused"`).
|
||||||
|
backend_id: String,
|
||||||
|
/// Coarse bound input class (e.g. `"keyboard"`); empty when
|
||||||
|
/// no binding is active.
|
||||||
|
bound_input_class: String,
|
||||||
|
},
|
||||||
|
/// Voice subsystem state snapshot (SDD-094). Emitted on
|
||||||
|
/// `voice_join` / `voice_leave`, transmit-mode changes,
|
||||||
|
/// hard-mute toggles, and release-tail edits.
|
||||||
|
VoiceState {
|
||||||
|
/// True when the user has joined a voice channel via
|
||||||
|
/// `voice_join` and the audio engine is running.
|
||||||
|
in_channel: bool,
|
||||||
|
/// Active transmit mode encoded as
|
||||||
|
/// [`chanora_audio::TransmitMode::as_u8`].
|
||||||
|
transmit_mode: u8,
|
||||||
|
/// True when the hard-mute clamp is engaged.
|
||||||
|
mute: bool,
|
||||||
|
/// Current release-tail in milliseconds (0..=500).
|
||||||
|
release_tail_ms: u32,
|
||||||
|
/// Last confirmed authoritative channel id from the
|
||||||
|
/// `channel_join` reducer projection.
|
||||||
|
current_channel_id: Option<u64>,
|
||||||
|
/// Non-authoritative pending target channel id from the
|
||||||
|
/// reducer projection.
|
||||||
|
pending_target_channel_id: Option<u64>,
|
||||||
|
/// Whether the reducer currently allows a new join intent.
|
||||||
|
can_join: bool,
|
||||||
|
/// Whether the reducer currently allows leave intent.
|
||||||
|
can_leave: bool,
|
||||||
|
/// Join projection synchronization state.
|
||||||
|
join_sync_state: VoiceJoinSyncState,
|
||||||
|
/// Last stable sanitized join error code, if any.
|
||||||
|
join_error_code: Option<VoiceJoinErrorCode>,
|
||||||
|
},
|
||||||
|
/// iOS audio-session interruption state (SDD-101). Emitted when
|
||||||
|
/// interruption begins and when it ends (with the platform hint
|
||||||
|
/// indicating whether audio should resume).
|
||||||
|
InterruptionState {
|
||||||
|
/// True when interruption began, false when interruption ended.
|
||||||
|
began: bool,
|
||||||
|
/// Platform-provided resume hint. For begin events this is false.
|
||||||
|
should_resume: bool,
|
||||||
|
},
|
||||||
|
/// A text message was received from the server.
|
||||||
|
ChatMessage {
|
||||||
|
/// Client id of the sender.
|
||||||
|
sender_id: u64,
|
||||||
|
/// Nickname of the sender.
|
||||||
|
sender_name: String,
|
||||||
|
/// Message content.
|
||||||
|
message: String,
|
||||||
|
/// Target scope (server/channel/private/poke).
|
||||||
|
target: MessageTarget,
|
||||||
|
/// Poke notification strength, present only for poke messages.
|
||||||
|
poke_strength: Option<PokeStrength>,
|
||||||
|
},
|
||||||
|
/// Human-readable TeamSpeak-style server activity.
|
||||||
|
ServerActivity {
|
||||||
|
/// Activity line text.
|
||||||
|
message: String,
|
||||||
|
},
|
||||||
|
/// Audio route changed (speaker/earpiece/BT/wired headset).
|
||||||
|
AudioRouteChanged {
|
||||||
|
/// New audio output route.
|
||||||
|
route: AudioRoute,
|
||||||
|
},
|
||||||
|
/// A client moved to a different channel.
|
||||||
|
ClientMoved {
|
||||||
|
/// Unique client identifier.
|
||||||
|
client_id: u64,
|
||||||
|
/// Destination channel.
|
||||||
|
new_channel_id: u64,
|
||||||
|
},
|
||||||
|
/// A new client connected.
|
||||||
|
ClientJoined {
|
||||||
|
/// Unique client identifier.
|
||||||
|
client_id: u64,
|
||||||
|
/// Channel the client joined.
|
||||||
|
channel_id: u64,
|
||||||
|
/// Display nickname.
|
||||||
|
name: String,
|
||||||
|
/// Microphone muted state.
|
||||||
|
input_muted: bool,
|
||||||
|
/// Speaker muted state.
|
||||||
|
output_muted: bool,
|
||||||
|
/// Whether this is a server query (bot) client.
|
||||||
|
is_server_query: bool,
|
||||||
|
/// Client's talk power value.
|
||||||
|
talk_power: i32,
|
||||||
|
/// Whether the server granted temporary talk power.
|
||||||
|
talk_power_granted: bool,
|
||||||
|
},
|
||||||
|
/// A client disconnected.
|
||||||
|
ClientLeft {
|
||||||
|
/// Unique client identifier.
|
||||||
|
client_id: u64,
|
||||||
|
/// Display nickname at time of disconnect.
|
||||||
|
name: String,
|
||||||
|
},
|
||||||
|
/// Client properties changed.
|
||||||
|
ClientUpdated {
|
||||||
|
/// Unique client identifier.
|
||||||
|
client_id: u64,
|
||||||
|
/// Microphone muted state.
|
||||||
|
input_muted: bool,
|
||||||
|
/// Speaker muted state.
|
||||||
|
output_muted: bool,
|
||||||
|
/// Whether this is a server query (bot) client.
|
||||||
|
is_server_query: bool,
|
||||||
|
/// Client's talk power value.
|
||||||
|
talk_power: i32,
|
||||||
|
/// Whether the server granted temporary talk power.
|
||||||
|
talk_power_granted: bool,
|
||||||
|
},
|
||||||
|
/// A new channel appeared.
|
||||||
|
ChannelAdded {
|
||||||
|
/// Unique channel identifier.
|
||||||
|
id: u64,
|
||||||
|
/// Parent channel ID.
|
||||||
|
parent: u64,
|
||||||
|
/// Channel name.
|
||||||
|
name: String,
|
||||||
|
/// Predecessor channel ID within the same parent (TeamSpeak
|
||||||
|
/// linked-list ordering hint). Zero means first child.
|
||||||
|
order: i64,
|
||||||
|
/// Whether the channel requires a password.
|
||||||
|
has_password: bool,
|
||||||
|
/// Talk power required to speak, or `None` when unrestricted.
|
||||||
|
needed_talk_power: Option<i32>,
|
||||||
|
},
|
||||||
|
/// A channel was deleted.
|
||||||
|
ChannelRemoved {
|
||||||
|
/// Channel identifier.
|
||||||
|
id: u64,
|
||||||
|
},
|
||||||
|
/// Channel properties changed.
|
||||||
|
ChannelUpdated {
|
||||||
|
/// Unique channel identifier.
|
||||||
|
id: u64,
|
||||||
|
/// Channel name.
|
||||||
|
name: String,
|
||||||
|
/// Whether the channel requires a password.
|
||||||
|
has_password: bool,
|
||||||
|
/// Talk power required to speak, or `None` when unrestricted.
|
||||||
|
needed_talk_power: Option<i32>,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Bridge-safe mirror of channel-join projection sync state.
|
||||||
|
#[derive(Debug, Clone, Copy)]
|
||||||
|
pub enum VoiceJoinSyncState {
|
||||||
|
/// Reducer is ready to accept channel actions.
|
||||||
|
Ready,
|
||||||
|
/// Reducer is synchronizing against an initial snapshot.
|
||||||
|
SynchronizingInitialSnapshot,
|
||||||
|
/// Reducer is synchronizing after reconnect.
|
||||||
|
SynchronizingReconnect,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Bridge-safe mirror of stable channel-join error codes.
|
||||||
|
#[derive(Debug, Clone, Copy)]
|
||||||
|
pub enum VoiceJoinErrorCode {
|
||||||
|
/// Duplicate same-target join intent was coalesced.
|
||||||
|
DuplicateSameTargetCoalesced,
|
||||||
|
/// A different target was requested while one is already pending.
|
||||||
|
JoinAlreadyPendingDifferentTarget,
|
||||||
|
/// Join denied by server policy/permission.
|
||||||
|
JoinDenied,
|
||||||
|
/// Join failed due to protocol-level error.
|
||||||
|
JoinProtocolFailure,
|
||||||
|
/// Join failed due to transport/network error.
|
||||||
|
JoinNetworkFailure,
|
||||||
|
/// Join timed out awaiting confirmation.
|
||||||
|
JoinTimeout,
|
||||||
|
/// Pending join was superseded by user leave.
|
||||||
|
JoinSupersededByLeave,
|
||||||
|
/// Stale join outcome was ignored.
|
||||||
|
JoinStaleOutcomeIgnored,
|
||||||
|
/// Authoritative membership reconciled to different channel.
|
||||||
|
JoinReconciledDifferentChannel,
|
||||||
|
/// Join command was rejected before send acceptance.
|
||||||
|
JoinCommandRejectedBeforeSend,
|
||||||
|
/// Join intent rejected while reducer synchronizing.
|
||||||
|
JoinCannotStartWhileSynchronizing,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Coarse OS-reported network state. Populated by the Flutter side
|
||||||
|
/// via `connectivity_plus`; on platforms where no signal is wired
|
||||||
|
/// we stay at `Unknown` forever and the supervisor falls back to
|
||||||
|
/// pure watchdog/backoff behaviour.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum NetworkState {
|
||||||
|
/// No signal seen yet — treat as ambiguous; don't change behaviour.
|
||||||
|
Unknown,
|
||||||
|
/// OS reports at least one network with internet capability.
|
||||||
|
Online,
|
||||||
|
/// OS reports no networks available.
|
||||||
|
Offline,
|
||||||
|
}
|
||||||
+184
-343
@@ -52,6 +52,8 @@ use chanora_state::channel_join::{
|
|||||||
ConnectionEpoch, JoinFailureKind,
|
ConnectionEpoch, JoinFailureKind,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
mod events;
|
||||||
|
mod network_diagnostics;
|
||||||
pub mod ptt;
|
pub mod ptt;
|
||||||
|
|
||||||
pub use chanora_audio::{
|
pub use chanora_audio::{
|
||||||
@@ -66,49 +68,14 @@ pub use chanora_diagnostics::{
|
|||||||
};
|
};
|
||||||
pub use chanora_protocol::{
|
pub use chanora_protocol::{
|
||||||
ChannelInfo, ChatMessage, ClientInfo, ClientProfile, ConnectConfig, DisconnectReason,
|
ChannelInfo, ChatMessage, ClientInfo, ClientProfile, ConnectConfig, DisconnectReason,
|
||||||
MessageTarget, ProtocolError, ServerActivity, ServerSnapshot,
|
MessageTarget, PokeStrength, ProtocolError, ServerActivity, ServerSnapshot,
|
||||||
};
|
};
|
||||||
pub use chanora_storage::{Bookmark, BookmarkRepository, IdentityFileStore};
|
pub use chanora_storage::{Bookmark, BookmarkRepository, IdentityFileStore};
|
||||||
|
pub use events::{
|
||||||
/// Privacy-safe snapshot of the active PTT capability.
|
NetworkState, PersistedPttBinding, PttDescriptorSnapshot, SessionEvent, VoiceJoinErrorCode,
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
VoiceJoinSyncState,
|
||||||
pub struct PttDescriptorSnapshot {
|
};
|
||||||
/// Stable capability level name.
|
use network_diagnostics::NetworkDiagnostics;
|
||||||
pub level: String,
|
|
||||||
/// Stable backend identifier.
|
|
||||||
pub backend_id: String,
|
|
||||||
/// Coarse bound input class; empty when no binding is active.
|
|
||||||
pub bound_input_class: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl From<PttBackendDescriptor> for PttDescriptorSnapshot {
|
|
||||||
fn from(desc: PttBackendDescriptor) -> Self {
|
|
||||||
Self {
|
|
||||||
level: desc.level.as_str().to_string(),
|
|
||||||
backend_id: desc.backend_id.to_string(),
|
|
||||||
bound_input_class: desc.bound_input_class.unwrap_or("").to_string(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Persisted PTT binding state exposed to callers.
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
||||||
pub struct PersistedPttBinding {
|
|
||||||
/// Stable input category string (`""`, `"keyboard"`, or
|
|
||||||
/// `"mouse-side-button"`).
|
|
||||||
pub input_class: String,
|
|
||||||
/// Display-only key label; empty when no binding is active.
|
|
||||||
pub key_label: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl PersistedPttBinding {
|
|
||||||
fn empty() -> Self {
|
|
||||||
Self {
|
|
||||||
input_class: String::new(),
|
|
||||||
key_label: String::new(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Errors that can arise during top-level orchestration.
|
/// Errors that can arise during top-level orchestration.
|
||||||
#[derive(Debug, Error)]
|
#[derive(Debug, Error)]
|
||||||
@@ -147,291 +114,11 @@ pub enum CoreError {
|
|||||||
Ptt(#[from] ptt::PttControllerError),
|
Ptt(#[from] ptt::PttControllerError),
|
||||||
}
|
}
|
||||||
|
|
||||||
/// High-level lifecycle event surfaced to subscribers.
|
|
||||||
///
|
|
||||||
/// This is the minimal set needed for A.6 (reconnect banner). The
|
|
||||||
/// full event catalogue lands in A.4.
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub enum SessionEvent {
|
|
||||||
/// Initial connect succeeded, or reconnect attempt succeeded.
|
|
||||||
Connected {
|
|
||||||
/// Server name reported in the snapshot.
|
|
||||||
server_name: String,
|
|
||||||
},
|
|
||||||
/// Connection lost; the supervisor will retry.
|
|
||||||
Lost {
|
|
||||||
/// Reason classification from the protocol layer.
|
|
||||||
reason: String,
|
|
||||||
},
|
|
||||||
/// Supervisor is sleeping before its next reconnect attempt.
|
|
||||||
Reconnecting {
|
|
||||||
/// 1-based attempt counter for the current outage.
|
|
||||||
attempt: u32,
|
|
||||||
/// Seconds the supervisor will sleep before this attempt.
|
|
||||||
delay_secs: u32,
|
|
||||||
},
|
|
||||||
/// Supervisor gave up after `attempt` failed retries (or the
|
|
||||||
/// user explicitly disconnected mid-outage).
|
|
||||||
Disconnected {
|
|
||||||
/// Reason classification from the protocol layer.
|
|
||||||
reason: String,
|
|
||||||
},
|
|
||||||
/// Audio engine started (e.g. after a successful reconnect with
|
|
||||||
/// reattachment).
|
|
||||||
AudioStarted,
|
|
||||||
/// Audio engine stopped (e.g. before a reconnect cycle, or by
|
|
||||||
/// explicit user action).
|
|
||||||
AudioStopped,
|
|
||||||
/// Detected desktop Push-to-Talk capability (gen2 v0.9.3 /
|
|
||||||
/// DEC-023..028). Published when the audio engine starts or
|
|
||||||
/// when the active backend transitions (for example macOS
|
|
||||||
/// permission state change). Carries only the privacy-safe
|
|
||||||
/// descriptor — capability level, backend identifier, bound
|
|
||||||
/// input class — per SRS-202 / DEC-027.
|
|
||||||
PttCapability {
|
|
||||||
/// Stable level name from `PttCapabilityLevel::as_str()`.
|
|
||||||
level: String,
|
|
||||||
/// Stable backend identifier (e.g. `"focused"`).
|
|
||||||
backend_id: String,
|
|
||||||
/// Coarse bound input class (e.g. `"keyboard"`); empty when
|
|
||||||
/// no binding is active.
|
|
||||||
bound_input_class: String,
|
|
||||||
},
|
|
||||||
/// Voice subsystem state snapshot (SDD-094). Emitted on
|
|
||||||
/// `voice_join` / `voice_leave`, transmit-mode changes,
|
|
||||||
/// hard-mute toggles, and release-tail edits.
|
|
||||||
VoiceState {
|
|
||||||
/// True when the user has joined a voice channel via
|
|
||||||
/// `voice_join` and the audio engine is running.
|
|
||||||
in_channel: bool,
|
|
||||||
/// Active transmit mode encoded as
|
|
||||||
/// [`chanora_audio::TransmitMode::as_u8`].
|
|
||||||
transmit_mode: u8,
|
|
||||||
/// True when the hard-mute clamp is engaged.
|
|
||||||
mute: bool,
|
|
||||||
/// Current release-tail in milliseconds (0..=500).
|
|
||||||
release_tail_ms: u32,
|
|
||||||
/// Last confirmed authoritative channel id from the
|
|
||||||
/// `channel_join` reducer projection.
|
|
||||||
current_channel_id: Option<u64>,
|
|
||||||
/// Non-authoritative pending target channel id from the
|
|
||||||
/// reducer projection.
|
|
||||||
pending_target_channel_id: Option<u64>,
|
|
||||||
/// Whether the reducer currently allows a new join intent.
|
|
||||||
can_join: bool,
|
|
||||||
/// Whether the reducer currently allows leave intent.
|
|
||||||
can_leave: bool,
|
|
||||||
/// Join projection synchronization state.
|
|
||||||
join_sync_state: VoiceJoinSyncState,
|
|
||||||
/// Last stable sanitized join error code, if any.
|
|
||||||
join_error_code: Option<VoiceJoinErrorCode>,
|
|
||||||
},
|
|
||||||
/// iOS audio-session interruption state (SDD-101). Emitted when
|
|
||||||
/// interruption begins and when it ends (with the platform hint
|
|
||||||
/// indicating whether audio should resume).
|
|
||||||
InterruptionState {
|
|
||||||
/// True when interruption began, false when interruption ended.
|
|
||||||
began: bool,
|
|
||||||
/// Platform-provided resume hint. For begin events this is false.
|
|
||||||
should_resume: bool,
|
|
||||||
},
|
|
||||||
/// A text message was received from the server.
|
|
||||||
ChatMessage {
|
|
||||||
/// Client id of the sender.
|
|
||||||
sender_id: u64,
|
|
||||||
/// Nickname of the sender.
|
|
||||||
sender_name: String,
|
|
||||||
/// Message content.
|
|
||||||
message: String,
|
|
||||||
/// Target scope (server/channel/private/poke).
|
|
||||||
target: MessageTarget,
|
|
||||||
},
|
|
||||||
/// Human-readable TeamSpeak-style server activity.
|
|
||||||
ServerActivity {
|
|
||||||
/// Activity line text.
|
|
||||||
message: String,
|
|
||||||
},
|
|
||||||
/// Audio route changed (speaker/earpiece/BT/wired headset).
|
|
||||||
AudioRouteChanged {
|
|
||||||
/// New audio output route.
|
|
||||||
route: AudioRoute,
|
|
||||||
},
|
|
||||||
/// A client moved to a different channel.
|
|
||||||
ClientMoved {
|
|
||||||
/// Unique client identifier.
|
|
||||||
client_id: u64,
|
|
||||||
/// Destination channel.
|
|
||||||
new_channel_id: u64,
|
|
||||||
},
|
|
||||||
/// A new client connected.
|
|
||||||
ClientJoined {
|
|
||||||
/// Unique client identifier.
|
|
||||||
client_id: u64,
|
|
||||||
/// Channel the client joined.
|
|
||||||
channel_id: u64,
|
|
||||||
/// Display nickname.
|
|
||||||
name: String,
|
|
||||||
/// Microphone muted state.
|
|
||||||
input_muted: bool,
|
|
||||||
/// Speaker muted state.
|
|
||||||
output_muted: bool,
|
|
||||||
/// Whether this is a server query (bot) client.
|
|
||||||
is_server_query: bool,
|
|
||||||
/// Client's talk power value.
|
|
||||||
talk_power: i32,
|
|
||||||
/// Whether the server granted temporary talk power.
|
|
||||||
talk_power_granted: bool,
|
|
||||||
},
|
|
||||||
/// A client disconnected.
|
|
||||||
ClientLeft {
|
|
||||||
/// Unique client identifier.
|
|
||||||
client_id: u64,
|
|
||||||
/// Display nickname at time of disconnect.
|
|
||||||
name: String,
|
|
||||||
},
|
|
||||||
/// Client properties changed.
|
|
||||||
ClientUpdated {
|
|
||||||
/// Unique client identifier.
|
|
||||||
client_id: u64,
|
|
||||||
/// Microphone muted state.
|
|
||||||
input_muted: bool,
|
|
||||||
/// Speaker muted state.
|
|
||||||
output_muted: bool,
|
|
||||||
/// Whether this is a server query (bot) client.
|
|
||||||
is_server_query: bool,
|
|
||||||
/// Client's talk power value.
|
|
||||||
talk_power: i32,
|
|
||||||
/// Whether the server granted temporary talk power.
|
|
||||||
talk_power_granted: bool,
|
|
||||||
},
|
|
||||||
/// A new channel appeared.
|
|
||||||
ChannelAdded {
|
|
||||||
/// Unique channel identifier.
|
|
||||||
id: u64,
|
|
||||||
/// Parent channel ID.
|
|
||||||
parent: u64,
|
|
||||||
/// Channel name.
|
|
||||||
name: String,
|
|
||||||
/// Predecessor channel ID within the same parent (TeamSpeak
|
|
||||||
/// linked-list ordering hint). Zero means first child.
|
|
||||||
order: i64,
|
|
||||||
/// Whether the channel requires a password.
|
|
||||||
has_password: bool,
|
|
||||||
/// Talk power required to speak, or `None` when unrestricted.
|
|
||||||
needed_talk_power: Option<i32>,
|
|
||||||
},
|
|
||||||
/// A channel was deleted.
|
|
||||||
ChannelRemoved {
|
|
||||||
/// Channel identifier.
|
|
||||||
id: u64,
|
|
||||||
},
|
|
||||||
/// Channel properties changed.
|
|
||||||
ChannelUpdated {
|
|
||||||
/// Unique channel identifier.
|
|
||||||
id: u64,
|
|
||||||
/// Channel name.
|
|
||||||
name: String,
|
|
||||||
/// Whether the channel requires a password.
|
|
||||||
has_password: bool,
|
|
||||||
/// Talk power required to speak, or `None` when unrestricted.
|
|
||||||
needed_talk_power: Option<i32>,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Bridge-safe mirror of channel-join projection sync state.
|
|
||||||
#[derive(Debug, Clone, Copy)]
|
|
||||||
pub enum VoiceJoinSyncState {
|
|
||||||
/// Reducer is ready to accept channel actions.
|
|
||||||
Ready,
|
|
||||||
/// Reducer is synchronizing against an initial snapshot.
|
|
||||||
SynchronizingInitialSnapshot,
|
|
||||||
/// Reducer is synchronizing after reconnect.
|
|
||||||
SynchronizingReconnect,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Bridge-safe mirror of stable channel-join error codes.
|
|
||||||
#[derive(Debug, Clone, Copy)]
|
|
||||||
pub enum VoiceJoinErrorCode {
|
|
||||||
/// Duplicate same-target join intent was coalesced.
|
|
||||||
DuplicateSameTargetCoalesced,
|
|
||||||
/// A different target was requested while one is already pending.
|
|
||||||
JoinAlreadyPendingDifferentTarget,
|
|
||||||
/// Join denied by server policy/permission.
|
|
||||||
JoinDenied,
|
|
||||||
/// Join failed due to protocol-level error.
|
|
||||||
JoinProtocolFailure,
|
|
||||||
/// Join failed due to transport/network error.
|
|
||||||
JoinNetworkFailure,
|
|
||||||
/// Join timed out awaiting confirmation.
|
|
||||||
JoinTimeout,
|
|
||||||
/// Pending join was superseded by user leave.
|
|
||||||
JoinSupersededByLeave,
|
|
||||||
/// Stale join outcome was ignored.
|
|
||||||
JoinStaleOutcomeIgnored,
|
|
||||||
/// Authoritative membership reconciled to different channel.
|
|
||||||
JoinReconciledDifferentChannel,
|
|
||||||
/// Join command was rejected before send acceptance.
|
|
||||||
JoinCommandRejectedBeforeSend,
|
|
||||||
/// Join intent rejected while reducer synchronizing.
|
|
||||||
JoinCannotStartWhileSynchronizing,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Coarse OS-reported network state. Populated by the Flutter side
|
|
||||||
/// via `connectivity_plus`; on platforms where no signal is wired
|
|
||||||
/// we stay at `Unknown` forever and the supervisor falls back to
|
|
||||||
/// pure watchdog/backoff behaviour.
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
||||||
pub enum NetworkState {
|
|
||||||
/// No signal seen yet — treat as ambiguous; don't change behaviour.
|
|
||||||
Unknown,
|
|
||||||
/// OS reports at least one network with internet capability.
|
|
||||||
Online,
|
|
||||||
/// OS reports no networks available.
|
|
||||||
Offline,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Channel capacity for the broadcast events. Generous because
|
/// Channel capacity for the broadcast events. Generous because
|
||||||
/// reconnect cycles emit several events per attempt; if subscribers
|
/// reconnect cycles emit several events per attempt; if subscribers
|
||||||
/// fall behind we'd rather skip than block the supervisor.
|
/// fall behind we'd rather skip than block the supervisor.
|
||||||
const EVENT_CHANNEL_CAPACITY: usize = 64;
|
const EVENT_CHANNEL_CAPACITY: usize = 64;
|
||||||
|
|
||||||
/// Network diagnostics snapshot collected across connection lifetimes.
|
|
||||||
#[derive(Debug, Clone, Default)]
|
|
||||||
struct NetworkDiagnostics {
|
|
||||||
/// Total count of connects (including the initial one).
|
|
||||||
connect_count: u64,
|
|
||||||
/// Count of disconnects (graceful + loss).
|
|
||||||
disconnect_count: u64,
|
|
||||||
/// Recent loss reasons (last 8, ring buffer).
|
|
||||||
loss_reasons: Vec<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl NetworkDiagnostics {
|
|
||||||
fn record_connect(&mut self) {
|
|
||||||
self.connect_count = self.connect_count.saturating_add(1);
|
|
||||||
}
|
|
||||||
fn record_loss(&mut self, reason: &str) {
|
|
||||||
self.disconnect_count = self.disconnect_count.saturating_add(1);
|
|
||||||
if self.loss_reasons.len() >= 8 {
|
|
||||||
self.loss_reasons.remove(0);
|
|
||||||
}
|
|
||||||
self.loss_reasons.push(reason.to_string());
|
|
||||||
}
|
|
||||||
fn summary(&self) -> String {
|
|
||||||
let mut s = format!(
|
|
||||||
"connects: {}\ndisconnects: {}\n",
|
|
||||||
self.connect_count, self.disconnect_count
|
|
||||||
);
|
|
||||||
if !self.loss_reasons.is_empty() {
|
|
||||||
s.push_str(&format!(
|
|
||||||
"loss_reasons: [{}]\n",
|
|
||||||
self.loss_reasons.join(", ")
|
|
||||||
));
|
|
||||||
}
|
|
||||||
s
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
struct SupervisorInner {
|
struct SupervisorInner {
|
||||||
/// Optional cached AudioEngineConfig — set when start_audio is
|
/// Optional cached AudioEngineConfig — set when start_audio is
|
||||||
/// first called, used to re-create the engine after a reconnect.
|
/// first called, used to re-create the engine after a reconnect.
|
||||||
@@ -477,12 +164,20 @@ struct ConnectedState {
|
|||||||
local_output_muted: bool,
|
local_output_muted: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn take_disconnect_state<T>(inner: &Arc<Mutex<Option<T>>>) -> Option<T> {
|
||||||
|
inner.lock().await.take()
|
||||||
|
}
|
||||||
|
|
||||||
fn normalize_channel_password(password: Option<String>) -> Option<String> {
|
fn normalize_channel_password(password: Option<String>) -> Option<String> {
|
||||||
password
|
password
|
||||||
.map(|p| p.trim().to_string())
|
.map(|p| p.trim().to_string())
|
||||||
.filter(|p| !p.is_empty())
|
.filter(|p| !p.is_empty())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn should_dispatch_text_message(message: &str, target: &MessageTarget) -> bool {
|
||||||
|
!message.trim().is_empty() || matches!(target, MessageTarget::Poke(_))
|
||||||
|
}
|
||||||
|
|
||||||
/// The top-level Chanora session. Owns at most one active server
|
/// The top-level Chanora session. Owns at most one active server
|
||||||
/// connection (DEC-006).
|
/// connection (DEC-006).
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
@@ -982,7 +677,7 @@ impl ChanoraSession {
|
|||||||
message: String,
|
message: String,
|
||||||
target: MessageTarget,
|
target: MessageTarget,
|
||||||
) -> Result<(), CoreError> {
|
) -> Result<(), CoreError> {
|
||||||
if message.trim().is_empty() {
|
if !should_dispatch_text_message(&message, &target) {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
let guard = self.inner.lock().await;
|
let guard = self.inner.lock().await;
|
||||||
@@ -1878,8 +1573,7 @@ impl ChanoraSession {
|
|||||||
|
|
||||||
/// Disconnect from the server. No-op if not connected.
|
/// Disconnect from the server. No-op if not connected.
|
||||||
pub async fn disconnect(&self) -> Result<(), CoreError> {
|
pub async fn disconnect(&self) -> Result<(), CoreError> {
|
||||||
let mut guard = self.inner.lock().await;
|
if let Some(mut state) = take_disconnect_state(&self.inner).await {
|
||||||
if let Some(mut state) = guard.take() {
|
|
||||||
// Signal the supervisor to exit (cancels any backoff sleep).
|
// Signal the supervisor to exit (cancels any backoff sleep).
|
||||||
if let Some(tx) = state.cancel_tx.take() {
|
if let Some(tx) = state.cancel_tx.take() {
|
||||||
let _ = tx.send(());
|
let _ = tx.send(());
|
||||||
@@ -1895,7 +1589,7 @@ impl ChanoraSession {
|
|||||||
// Wait for the supervisor to wind down so we don't race
|
// Wait for the supervisor to wind down so we don't race
|
||||||
// a redial against the explicit disconnect.
|
// a redial against the explicit disconnect.
|
||||||
if let Some(handle) = state.supervisor.take() {
|
if let Some(handle) = state.supervisor.take() {
|
||||||
let _ = handle.await;
|
await_supervisor_shutdown(handle, SUPERVISOR_SHUTDOWN_TIMEOUT).await;
|
||||||
}
|
}
|
||||||
let _ = self.events_tx.send(SessionEvent::Disconnected {
|
let _ = self.events_tx.send(SessionEvent::Disconnected {
|
||||||
reason: "user requested".to_string(),
|
reason: "user requested".to_string(),
|
||||||
@@ -1934,6 +1628,22 @@ const WATCHDOG_PROBE_TIMEOUT: Duration = Duration::from_secs(4);
|
|||||||
/// Number of consecutive watchdog failures before the supervisor
|
/// Number of consecutive watchdog failures before the supervisor
|
||||||
/// declares the connection lost.
|
/// declares the connection lost.
|
||||||
const WATCHDOG_MAX_MISSES: u32 = 3;
|
const WATCHDOG_MAX_MISSES: u32 = 3;
|
||||||
|
const SUPERVISOR_SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(1);
|
||||||
|
|
||||||
|
async fn await_supervisor_shutdown(mut handle: JoinHandle<()>, timeout_duration: Duration) {
|
||||||
|
if tokio::time::timeout(timeout_duration, &mut handle)
|
||||||
|
.await
|
||||||
|
.is_err()
|
||||||
|
{
|
||||||
|
warn!(
|
||||||
|
target: "chanora_core",
|
||||||
|
timeout_ms = timeout_duration.as_millis() as u64,
|
||||||
|
"supervisor did not stop before shutdown timeout"
|
||||||
|
);
|
||||||
|
handle.abort();
|
||||||
|
let _ = handle.await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
struct SupervisorContext {
|
struct SupervisorContext {
|
||||||
state_arc: Arc<Mutex<Option<ConnectedState>>>,
|
state_arc: Arc<Mutex<Option<ConnectedState>>>,
|
||||||
@@ -1968,6 +1678,7 @@ fn spawn_event_forwarders(
|
|||||||
sender_name: msg.sender_name,
|
sender_name: msg.sender_name,
|
||||||
message: msg.message,
|
message: msg.message,
|
||||||
target: msg.target,
|
target: msg.target,
|
||||||
|
poke_strength: msg.poke_strength,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -1989,27 +1700,77 @@ fn spawn_event_forwarders(
|
|||||||
let mut rx = delta_rx;
|
let mut rx = delta_rx;
|
||||||
while let Some(delta) = rx.recv().await {
|
while let Some(delta) = rx.recv().await {
|
||||||
let event = match delta {
|
let event = match delta {
|
||||||
ProtocolDelta::ClientMoved { client_id, new_channel_id } => {
|
ProtocolDelta::ClientMoved {
|
||||||
SessionEvent::ClientMoved { client_id, new_channel_id }
|
client_id,
|
||||||
}
|
new_channel_id,
|
||||||
ProtocolDelta::ClientJoined { client_id, channel_id, name, input_muted, output_muted, is_server_query, talk_power, talk_power_granted } => {
|
} => SessionEvent::ClientMoved {
|
||||||
SessionEvent::ClientJoined { client_id, channel_id, name, input_muted, output_muted, is_server_query, talk_power, talk_power_granted }
|
client_id,
|
||||||
}
|
new_channel_id,
|
||||||
|
},
|
||||||
|
ProtocolDelta::ClientJoined {
|
||||||
|
client_id,
|
||||||
|
channel_id,
|
||||||
|
name,
|
||||||
|
input_muted,
|
||||||
|
output_muted,
|
||||||
|
is_server_query,
|
||||||
|
talk_power,
|
||||||
|
talk_power_granted,
|
||||||
|
} => SessionEvent::ClientJoined {
|
||||||
|
client_id,
|
||||||
|
channel_id,
|
||||||
|
name,
|
||||||
|
input_muted,
|
||||||
|
output_muted,
|
||||||
|
is_server_query,
|
||||||
|
talk_power,
|
||||||
|
talk_power_granted,
|
||||||
|
},
|
||||||
ProtocolDelta::ClientLeft { client_id, name } => {
|
ProtocolDelta::ClientLeft { client_id, name } => {
|
||||||
SessionEvent::ClientLeft { client_id, name }
|
SessionEvent::ClientLeft { client_id, name }
|
||||||
}
|
}
|
||||||
ProtocolDelta::ClientUpdated { client_id, input_muted, output_muted, is_server_query, talk_power, talk_power_granted } => {
|
ProtocolDelta::ClientUpdated {
|
||||||
SessionEvent::ClientUpdated { client_id, input_muted, output_muted, is_server_query, talk_power, talk_power_granted }
|
client_id,
|
||||||
}
|
input_muted,
|
||||||
ProtocolDelta::ChannelAdded { id, parent, name, order, has_password, needed_talk_power } => {
|
output_muted,
|
||||||
SessionEvent::ChannelAdded { id, parent, name, order, has_password, needed_talk_power }
|
is_server_query,
|
||||||
}
|
talk_power,
|
||||||
ProtocolDelta::ChannelRemoved { id } => {
|
talk_power_granted,
|
||||||
SessionEvent::ChannelRemoved { id }
|
} => SessionEvent::ClientUpdated {
|
||||||
}
|
client_id,
|
||||||
ProtocolDelta::ChannelUpdated { id, name, has_password, needed_talk_power } => {
|
input_muted,
|
||||||
SessionEvent::ChannelUpdated { id, name, has_password, needed_talk_power }
|
output_muted,
|
||||||
}
|
is_server_query,
|
||||||
|
talk_power,
|
||||||
|
talk_power_granted,
|
||||||
|
},
|
||||||
|
ProtocolDelta::ChannelAdded {
|
||||||
|
id,
|
||||||
|
parent,
|
||||||
|
name,
|
||||||
|
order,
|
||||||
|
has_password,
|
||||||
|
needed_talk_power,
|
||||||
|
} => SessionEvent::ChannelAdded {
|
||||||
|
id,
|
||||||
|
parent,
|
||||||
|
name,
|
||||||
|
order,
|
||||||
|
has_password,
|
||||||
|
needed_talk_power,
|
||||||
|
},
|
||||||
|
ProtocolDelta::ChannelRemoved { id } => SessionEvent::ChannelRemoved { id },
|
||||||
|
ProtocolDelta::ChannelUpdated {
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
has_password,
|
||||||
|
needed_talk_power,
|
||||||
|
} => SessionEvent::ChannelUpdated {
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
has_password,
|
||||||
|
needed_talk_power,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
let _ = ev_tx.send(event);
|
let _ = ev_tx.send(event);
|
||||||
}
|
}
|
||||||
@@ -2633,6 +2394,54 @@ mod tests {
|
|||||||
s.disconnect().await.unwrap();
|
s.disconnect().await.unwrap();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn disconnect_state_take_releases_inner_lock_before_teardown() {
|
||||||
|
let inner = Arc::new(Mutex::new(Some(())));
|
||||||
|
|
||||||
|
let state = super::take_disconnect_state(&inner).await;
|
||||||
|
|
||||||
|
assert_eq!(state, Some(()));
|
||||||
|
assert!(inner.try_lock().is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn supervisor_join_returns_after_shutdown_timeout() {
|
||||||
|
let handle = tokio::spawn(async {
|
||||||
|
std::future::pending::<()>().await;
|
||||||
|
});
|
||||||
|
let start = std::time::Instant::now();
|
||||||
|
|
||||||
|
super::await_supervisor_shutdown(handle, Duration::from_millis(10)).await;
|
||||||
|
|
||||||
|
assert!(start.elapsed() < Duration::from_millis(100));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn supervisor_shutdown_timeout_aborts_pending_task() {
|
||||||
|
struct DropNotice(Option<tokio::sync::oneshot::Sender<()>>);
|
||||||
|
|
||||||
|
impl Drop for DropNotice {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
if let Some(tx) = self.0.take() {
|
||||||
|
let _ = tx.send(());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let (dropped_tx, dropped_rx) = tokio::sync::oneshot::channel();
|
||||||
|
let handle = tokio::spawn(async move {
|
||||||
|
let _notice = DropNotice(Some(dropped_tx));
|
||||||
|
std::future::pending::<()>().await;
|
||||||
|
});
|
||||||
|
|
||||||
|
super::await_supervisor_shutdown(handle, Duration::from_millis(10)).await;
|
||||||
|
|
||||||
|
tokio::time::timeout(Duration::from_millis(100), dropped_rx)
|
||||||
|
.await
|
||||||
|
.expect("pending supervisor task should be aborted")
|
||||||
|
.expect("drop notice should be delivered");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn signature_detects_in_channel_move() {
|
fn signature_detects_in_channel_move() {
|
||||||
use chanora_protocol::{ChannelInfo, ClientInfo};
|
use chanora_protocol::{ChannelInfo, ClientInfo};
|
||||||
@@ -2730,6 +2539,38 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn empty_poke_messages_are_dispatchable() {
|
||||||
|
assert!(super::should_dispatch_text_message(
|
||||||
|
"",
|
||||||
|
&MessageTarget::Poke(42)
|
||||||
|
));
|
||||||
|
assert!(super::should_dispatch_text_message(
|
||||||
|
" \t ",
|
||||||
|
&MessageTarget::Poke(42)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn empty_non_poke_messages_remain_suppressed() {
|
||||||
|
assert!(!super::should_dispatch_text_message(
|
||||||
|
"",
|
||||||
|
&MessageTarget::Server
|
||||||
|
));
|
||||||
|
assert!(!super::should_dispatch_text_message(
|
||||||
|
" ",
|
||||||
|
&MessageTarget::Channel
|
||||||
|
));
|
||||||
|
assert!(!super::should_dispatch_text_message(
|
||||||
|
"",
|
||||||
|
&MessageTarget::Client(42)
|
||||||
|
));
|
||||||
|
assert!(super::should_dispatch_text_message(
|
||||||
|
"hello",
|
||||||
|
&MessageTarget::Channel
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn empty_address_is_rejected() {
|
async fn empty_address_is_rejected() {
|
||||||
let s = ChanoraSession::new();
|
let s = ChanoraSession::new();
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
use std::collections::VecDeque;
|
||||||
|
|
||||||
|
/// Network diagnostics snapshot collected across connection lifetimes.
|
||||||
|
#[derive(Debug, Clone, Default)]
|
||||||
|
pub(crate) struct NetworkDiagnostics {
|
||||||
|
/// Total count of connects (including the initial one).
|
||||||
|
connect_count: u64,
|
||||||
|
/// Count of disconnects (graceful + loss).
|
||||||
|
disconnect_count: u64,
|
||||||
|
/// Recent loss reasons (last 8, ring buffer).
|
||||||
|
loss_reasons: VecDeque<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl NetworkDiagnostics {
|
||||||
|
pub(crate) fn record_connect(&mut self) {
|
||||||
|
self.connect_count = self.connect_count.saturating_add(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn record_loss(&mut self, reason: &str) {
|
||||||
|
self.disconnect_count = self.disconnect_count.saturating_add(1);
|
||||||
|
if self.loss_reasons.len() >= 8 {
|
||||||
|
self.loss_reasons.pop_front();
|
||||||
|
}
|
||||||
|
self.loss_reasons.push_back(reason.to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn summary(&self) -> String {
|
||||||
|
let mut s = format!(
|
||||||
|
"connects: {}\ndisconnects: {}\n",
|
||||||
|
self.connect_count, self.disconnect_count
|
||||||
|
);
|
||||||
|
if !self.loss_reasons.is_empty() {
|
||||||
|
s.push_str(&format!(
|
||||||
|
"loss_reasons: [{}]\n",
|
||||||
|
self.loss_reasons
|
||||||
|
.iter()
|
||||||
|
.map(String::as_str)
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(", ")
|
||||||
|
));
|
||||||
|
}
|
||||||
|
s
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn network_diagnostics_keeps_last_eight_loss_reasons() {
|
||||||
|
let mut diagnostics = NetworkDiagnostics::default();
|
||||||
|
|
||||||
|
for i in 0..10 {
|
||||||
|
diagnostics.record_loss(&format!("loss-{i}"));
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_eq!(diagnostics.disconnect_count, 10);
|
||||||
|
assert_eq!(diagnostics.loss_reasons.len(), 8);
|
||||||
|
assert_eq!(
|
||||||
|
diagnostics.loss_reasons.front().map(String::as_str),
|
||||||
|
Some("loss-2")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
diagnostics.loss_reasons.back().map(String::as_str),
|
||||||
|
Some("loss-9")
|
||||||
|
);
|
||||||
|
assert!(diagnostics.summary().contains(
|
||||||
|
"loss_reasons: [loss-2, loss-3, loss-4, loss-5, loss-6, loss-7, loss-8, loss-9]"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use crossbeam::queue::ArrayQueue;
|
||||||
|
|
||||||
|
/// Fixed-capacity PCM handoff from the Android render producer task to
|
||||||
|
/// the Oboe output callback.
|
||||||
|
pub(crate) struct AndroidRenderRing {
|
||||||
|
frames: Arc<ArrayQueue<[f32; 2]>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AndroidRenderRing {
|
||||||
|
pub(crate) fn new(capacity: usize) -> Self {
|
||||||
|
Self {
|
||||||
|
frames: Arc::new(ArrayQueue::new((capacity / 2).max(1))),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn producer(&self) -> AndroidRenderRingProducer {
|
||||||
|
AndroidRenderRingProducer {
|
||||||
|
frames: Arc::clone(&self.frames),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn consumer(&self) -> AndroidRenderRingConsumer {
|
||||||
|
AndroidRenderRingConsumer {
|
||||||
|
frames: Arc::clone(&self.frames),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) struct AndroidRenderRingProducer {
|
||||||
|
frames: Arc<ArrayQueue<[f32; 2]>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AndroidRenderRingProducer {
|
||||||
|
pub(crate) fn push_frame_lossy(&self, samples: &[f32]) {
|
||||||
|
for frame in samples.chunks_exact(2) {
|
||||||
|
let stereo_frame = [frame[0], frame[1]];
|
||||||
|
if self.frames.push(stereo_frame).is_err() {
|
||||||
|
let _ = self.frames.pop();
|
||||||
|
let _ = self.frames.push(stereo_frame);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) struct AndroidRenderRingConsumer {
|
||||||
|
frames: Arc<ArrayQueue<[f32; 2]>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AndroidRenderRingConsumer {
|
||||||
|
#[cfg(test)]
|
||||||
|
pub(crate) fn drain_into_zero_filling(&self, out: &mut [f32]) {
|
||||||
|
let mut chunks = out.chunks_exact_mut(2);
|
||||||
|
for frame_out in &mut chunks {
|
||||||
|
let frame = self.frames.pop().unwrap_or([0.0, 0.0]);
|
||||||
|
frame_out.copy_from_slice(&frame);
|
||||||
|
}
|
||||||
|
for sample in chunks.into_remainder() {
|
||||||
|
*sample = 0.0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn drain_stereo_into_zero_filling(&self, out: &mut [(f32, f32)]) {
|
||||||
|
for frame_out in out {
|
||||||
|
let frame = self.frames.pop().unwrap_or([0.0, 0.0]);
|
||||||
|
*frame_out = (frame[0], frame[1]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn producer_drops_oldest_samples_when_ring_is_full() {
|
||||||
|
let ring = AndroidRenderRing::new(4);
|
||||||
|
let producer = ring.producer();
|
||||||
|
let consumer = ring.consumer();
|
||||||
|
|
||||||
|
producer.push_frame_lossy(&[1.0, 2.0, 3.0, 4.0]);
|
||||||
|
producer.push_frame_lossy(&[5.0, 6.0]);
|
||||||
|
|
||||||
|
let mut out = [0.0; 4];
|
||||||
|
consumer.drain_into_zero_filling(&mut out);
|
||||||
|
|
||||||
|
assert_eq!(out, [3.0, 4.0, 5.0, 6.0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn overflow_after_partial_consumer_drain_preserves_stereo_pairing() {
|
||||||
|
let ring = AndroidRenderRing::new(4);
|
||||||
|
let producer = ring.producer();
|
||||||
|
let consumer = ring.consumer();
|
||||||
|
|
||||||
|
producer.push_frame_lossy(&[1.0, 10.0, 2.0, 20.0]);
|
||||||
|
|
||||||
|
let mut odd_out = [9.0];
|
||||||
|
consumer.drain_into_zero_filling(&mut odd_out);
|
||||||
|
assert_eq!(odd_out, [0.0]);
|
||||||
|
|
||||||
|
producer.push_frame_lossy(&[3.0, 30.0]);
|
||||||
|
|
||||||
|
let mut out = [0.0; 4];
|
||||||
|
consumer.drain_into_zero_filling(&mut out);
|
||||||
|
|
||||||
|
assert_eq!(out, [2.0, 20.0, 3.0, 30.0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn consumer_zero_fills_tail_on_underrun() {
|
||||||
|
let ring = AndroidRenderRing::new(4);
|
||||||
|
let producer = ring.producer();
|
||||||
|
let consumer = ring.consumer();
|
||||||
|
|
||||||
|
producer.push_frame_lossy(&[0.25, -0.25]);
|
||||||
|
|
||||||
|
let mut out = [9.0; 4];
|
||||||
|
consumer.drain_into_zero_filling(&mut out);
|
||||||
|
|
||||||
|
assert_eq!(out, [0.25, -0.25, 0.0, 0.0]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -53,7 +53,6 @@ use crate::mobile_voice_backend::{
|
|||||||
BackendEventTx, EffectEngagement, EffectEngine, InputPresetChoice, MobileVoiceAudioBackend,
|
BackendEventTx, EffectEngagement, EffectEngine, InputPresetChoice, MobileVoiceAudioBackend,
|
||||||
SharingModeChoice, VoiceAudioParams,
|
SharingModeChoice, VoiceAudioParams,
|
||||||
};
|
};
|
||||||
use chanora_protocol::OutPacket;
|
|
||||||
use tsclientlib::audio::AudioHandler;
|
use tsclientlib::audio::AudioHandler;
|
||||||
|
|
||||||
use crate::{engine::SessionAudioId, AudioError};
|
use crate::{engine::SessionAudioId, AudioError};
|
||||||
@@ -86,40 +85,11 @@ use crate::processor::AudioProcessor;
|
|||||||
|
|
||||||
const RENDER_REF_SLOTS: usize = 4;
|
const RENDER_REF_SLOTS: usize = 4;
|
||||||
const RENDER_REF_SAMPLES: usize = crate::frame::FRAME_10MS_SAMPLES;
|
const RENDER_REF_SAMPLES: usize = crate::frame::FRAME_10MS_SAMPLES;
|
||||||
|
const ANDROID_RENDER_PULL_SAMPLES: usize = crate::frame::FRAME_20MS_SAMPLES * 2;
|
||||||
|
const ANDROID_RENDER_RING_CAPACITY: usize = ANDROID_RENDER_PULL_SAMPLES * 5;
|
||||||
|
|
||||||
struct RenderReferenceBuffer {
|
type RenderReferenceBuffer =
|
||||||
buf: Box<[[f32; RENDER_REF_SAMPLES]; RENDER_REF_SLOTS]>,
|
crate::render_reference::RenderReferenceBuffer<RENDER_REF_SAMPLES, RENDER_REF_SLOTS>;
|
||||||
write_idx: std::sync::atomic::AtomicUsize,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl RenderReferenceBuffer {
|
|
||||||
fn new() -> Arc<Self> {
|
|
||||||
Arc::new(Self {
|
|
||||||
buf: Box::new([[0.0_f32; RENDER_REF_SAMPLES]; RENDER_REF_SLOTS]),
|
|
||||||
write_idx: std::sync::atomic::AtomicUsize::new(0),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
fn write(&self, frame: &[f32; RENDER_REF_SAMPLES]) {
|
|
||||||
let idx = self.write_idx.load(Ordering::Relaxed);
|
|
||||||
unsafe {
|
|
||||||
let slot = &self.buf[idx] as *const [f32; RENDER_REF_SAMPLES]
|
|
||||||
as *mut [f32; RENDER_REF_SAMPLES];
|
|
||||||
(*slot).copy_from_slice(frame);
|
|
||||||
}
|
|
||||||
self.write_idx
|
|
||||||
.store((idx + 1) % RENDER_REF_SLOTS, Ordering::Relaxed);
|
|
||||||
}
|
|
||||||
|
|
||||||
fn read_latest(&self) -> [f32; RENDER_REF_SAMPLES] {
|
|
||||||
let wi = self.write_idx.load(Ordering::Relaxed);
|
|
||||||
let ri = (wi + RENDER_REF_SLOTS - 1) % RENDER_REF_SLOTS;
|
|
||||||
self.buf[ri]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
unsafe impl Send for RenderReferenceBuffer {}
|
|
||||||
unsafe impl Sync for RenderReferenceBuffer {}
|
|
||||||
|
|
||||||
// --- Capture state for Oboe input callback (SDD-111 / SDD-120) ----
|
// --- Capture state for Oboe input callback (SDD-111 / SDD-120) ----
|
||||||
//
|
//
|
||||||
@@ -138,9 +108,8 @@ struct AndroidCaptureState {
|
|||||||
encoder: OpusEncoder,
|
encoder: OpusEncoder,
|
||||||
pcm_accum: Vec<i16>,
|
pcm_accum: Vec<i16>,
|
||||||
opus_out: [u8; crate::opus_voice::MAX_OPUS_FRAME],
|
opus_out: [u8; crate::opus_voice::MAX_OPUS_FRAME],
|
||||||
voice_out_tx: mpsc::Sender<OutPacket>,
|
voice_out_tx: crate::opus_voice::EncodedVoiceFrameSender,
|
||||||
transmit_active: Arc<AtomicBool>,
|
transmit_active: Arc<AtomicBool>,
|
||||||
frames_sent: Arc<AtomicU32>,
|
|
||||||
mic_gain: f32,
|
mic_gain: f32,
|
||||||
voice_activity_selector: Option<Arc<crate::TransmitModeSelector>>,
|
voice_activity_selector: Option<Arc<crate::TransmitModeSelector>>,
|
||||||
vad_detector: crate::vad::WebRtcFallbackVad,
|
vad_detector: crate::vad::WebRtcFallbackVad,
|
||||||
@@ -164,7 +133,7 @@ struct AndroidCaptureState {
|
|||||||
|
|
||||||
impl AndroidCaptureState {
|
impl AndroidCaptureState {
|
||||||
fn new(
|
fn new(
|
||||||
voice_out_tx: mpsc::Sender<OutPacket>,
|
voice_out_tx: mpsc::Sender<chanora_protocol::OutPacket>,
|
||||||
transmit_active: Arc<AtomicBool>,
|
transmit_active: Arc<AtomicBool>,
|
||||||
frames_sent: Arc<AtomicU32>,
|
frames_sent: Arc<AtomicU32>,
|
||||||
mic_gain: f32,
|
mic_gain: f32,
|
||||||
@@ -188,9 +157,12 @@ impl AndroidCaptureState {
|
|||||||
encoder,
|
encoder,
|
||||||
pcm_accum: Vec::with_capacity(crate::frame::FRAME_20MS_SAMPLES * 2),
|
pcm_accum: Vec::with_capacity(crate::frame::FRAME_20MS_SAMPLES * 2),
|
||||||
opus_out: [0u8; crate::opus_voice::MAX_OPUS_FRAME],
|
opus_out: [0u8; crate::opus_voice::MAX_OPUS_FRAME],
|
||||||
voice_out_tx,
|
voice_out_tx: crate::opus_voice::start_out_packet_worker(
|
||||||
|
voice_out_tx,
|
||||||
|
frames_sent.clone(),
|
||||||
|
"android",
|
||||||
|
)?,
|
||||||
transmit_active,
|
transmit_active,
|
||||||
frames_sent,
|
|
||||||
mic_gain,
|
mic_gain,
|
||||||
voice_activity_selector,
|
voice_activity_selector,
|
||||||
vad_detector: crate::vad::WebRtcFallbackVad::default(),
|
vad_detector: crate::vad::WebRtcFallbackVad::default(),
|
||||||
@@ -221,8 +193,10 @@ impl AndroidCaptureState {
|
|||||||
self.audio_processing_stats
|
self.audio_processing_stats
|
||||||
.record_callback_frames(samples.len() as u64);
|
.record_callback_frames(samples.len() as u64);
|
||||||
if self.input_sample_rate_hz != crate::frame::SAMPLE_RATE_HZ {
|
if self.input_sample_rate_hz != crate::frame::SAMPLE_RATE_HZ {
|
||||||
let resampled = self.resample_capture_to_48k(samples);
|
self.resample_capture_to_48k(samples);
|
||||||
|
let resampled = std::mem::take(&mut self.resample_scratch);
|
||||||
self.ingest_48k_i16(&resampled);
|
self.ingest_48k_i16(&resampled);
|
||||||
|
self.resample_scratch = resampled;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
self.ingest_48k_i16(samples);
|
self.ingest_48k_i16(samples);
|
||||||
@@ -241,6 +215,7 @@ impl AndroidCaptureState {
|
|||||||
if self.pending_10ms_len == crate::frame::FRAME_10MS_SAMPLES {
|
if self.pending_10ms_len == crate::frame::FRAME_10MS_SAMPLES {
|
||||||
let frame = self.pending_10ms;
|
let frame = self.pending_10ms;
|
||||||
self.process_10ms_capture_frame(&frame);
|
self.process_10ms_capture_frame(&frame);
|
||||||
|
self.encode_complete_20ms_frames();
|
||||||
self.pending_10ms_len = 0;
|
self.pending_10ms_len = 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -250,6 +225,10 @@ impl AndroidCaptureState {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
self.encode_complete_20ms_frames();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn encode_complete_20ms_frames(&mut self) {
|
||||||
while self.pcm_accum.len() >= crate::frame::FRAME_20MS_SAMPLES {
|
while self.pcm_accum.len() >= crate::frame::FRAME_20MS_SAMPLES {
|
||||||
let mut frame = [0i16; crate::frame::FRAME_20MS_SAMPLES];
|
let mut frame = [0i16; crate::frame::FRAME_20MS_SAMPLES];
|
||||||
frame.copy_from_slice(&self.pcm_accum[..crate::frame::FRAME_20MS_SAMPLES]);
|
frame.copy_from_slice(&self.pcm_accum[..crate::frame::FRAME_20MS_SAMPLES]);
|
||||||
@@ -258,7 +237,6 @@ impl AndroidCaptureState {
|
|||||||
Ok(len) => {
|
Ok(len) => {
|
||||||
crate::opus_voice::send_voip_frame(
|
crate::opus_voice::send_voip_frame(
|
||||||
&self.voice_out_tx,
|
&self.voice_out_tx,
|
||||||
&self.frames_sent,
|
|
||||||
&self.opus_out,
|
&self.opus_out,
|
||||||
len,
|
len,
|
||||||
|| {
|
|| {
|
||||||
@@ -286,35 +264,18 @@ impl AndroidCaptureState {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn resample_capture_to_48k(&mut self, samples: &[i16]) -> Vec<i16> {
|
fn resample_capture_to_48k(&mut self, samples: &[i16]) -> usize {
|
||||||
if samples.is_empty() {
|
let result = crate::capture_resampler::resample_capture_to_48k(
|
||||||
return Vec::new();
|
samples,
|
||||||
|
self.input_sample_rate_hz,
|
||||||
|
&mut self.resample_pos,
|
||||||
|
&mut self.resample_last,
|
||||||
|
&mut self.resample_scratch,
|
||||||
|
);
|
||||||
|
if result.dropped {
|
||||||
|
self.audio_processing_stats.increment_callback_xrun();
|
||||||
}
|
}
|
||||||
self.resample_scratch.clear();
|
result.output_len
|
||||||
let ratio = self.input_sample_rate_hz as f64 / crate::frame::SAMPLE_RATE_HZ as f64;
|
|
||||||
let mut pos = self.resample_pos;
|
|
||||||
while pos < samples.len() as f64 {
|
|
||||||
let i = pos.floor() as isize;
|
|
||||||
let frac = pos - i as f64;
|
|
||||||
let a = if i <= 0 {
|
|
||||||
self.resample_last as f64
|
|
||||||
} else {
|
|
||||||
samples[(i - 1) as usize] as f64
|
|
||||||
};
|
|
||||||
let b = if i < samples.len() as isize {
|
|
||||||
samples[i as usize] as f64
|
|
||||||
} else {
|
|
||||||
a
|
|
||||||
};
|
|
||||||
let value = (a + frac * (b - a))
|
|
||||||
.round()
|
|
||||||
.clamp(i16::MIN as f64, i16::MAX as f64) as i16;
|
|
||||||
self.resample_scratch.push(value);
|
|
||||||
pos += ratio;
|
|
||||||
}
|
|
||||||
self.resample_pos = pos - samples.len() as f64;
|
|
||||||
self.resample_last = *samples.last().unwrap_or(&self.resample_last);
|
|
||||||
self.resample_scratch.clone()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn set_input_sample_rate_hz(&mut self, sample_rate_hz: u32) {
|
fn set_input_sample_rate_hz(&mut self, sample_rate_hz: u32) {
|
||||||
@@ -393,15 +354,9 @@ impl AndroidCaptureState {
|
|||||||
self.fallback_warned_backend = None;
|
self.fallback_warned_backend = None;
|
||||||
match vad_backend {
|
match vad_backend {
|
||||||
crate::VadBackend::SileroOnnx => {
|
crate::VadBackend::SileroOnnx => {
|
||||||
let path = crate::vad::silero_model_bundle_path();
|
self.silero_vad_worker = None;
|
||||||
self.silero_vad_worker =
|
self.mark_vad_fallback_active(crate::VadBackend::SileroOnnx);
|
||||||
crate::vad::silero_onnx::SileroOnnxVadWorker::try_new(&path);
|
self.audio_processing_stats.set_vad_fallback_active(true);
|
||||||
if self.silero_vad_worker.is_none() {
|
|
||||||
warn!(
|
|
||||||
target: "chanora_audio",
|
|
||||||
"android: Silero VAD model not found at {path}; falling back to WebRTC VAD"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
_ => {
|
_ => {
|
||||||
self.silero_vad_worker = None;
|
self.silero_vad_worker = None;
|
||||||
@@ -420,20 +375,38 @@ impl AndroidCaptureState {
|
|||||||
speech: true,
|
speech: true,
|
||||||
}
|
}
|
||||||
} else if vad_backend == crate::VadBackend::SileroOnnx {
|
} else if vad_backend == crate::VadBackend::SileroOnnx {
|
||||||
if let Some(worker) = self.silero_vad_worker.as_ref() {
|
match crate::vad::callback_vad_worker_policy(
|
||||||
let enqueued = worker.try_send(capture_seq, &frame);
|
voice_activity_mode,
|
||||||
if !worker.is_stale(capture_seq) {
|
vad_backend,
|
||||||
let p = worker.latest_probability();
|
self.silero_vad_worker.is_some(),
|
||||||
crate::vad::VadOutput {
|
) {
|
||||||
probability: p,
|
crate::vad::VadWorkerPolicy::UseWorker => {
|
||||||
speech: p >= 0.5,
|
let worker = self
|
||||||
|
.silero_vad_worker
|
||||||
|
.as_ref()
|
||||||
|
.expect("policy checked worker");
|
||||||
|
let enqueued = worker.try_send(capture_seq, &frame);
|
||||||
|
if !worker.is_stale(capture_seq) {
|
||||||
|
let p = worker.latest_probability();
|
||||||
|
crate::vad::VadOutput {
|
||||||
|
probability: p,
|
||||||
|
speech: p >= 0.5,
|
||||||
|
}
|
||||||
|
} else if enqueued {
|
||||||
|
crate::vad::VadOutput {
|
||||||
|
probability: 0.0,
|
||||||
|
speech: false,
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
used_fallback_vad = true;
|
||||||
|
self.mark_vad_fallback_active(vad_backend);
|
||||||
|
crate::vad::VoiceActivityDetector::process_10ms(
|
||||||
|
&mut self.vad_detector,
|
||||||
|
&frame,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
} else if enqueued {
|
}
|
||||||
crate::vad::VadOutput {
|
crate::vad::VadWorkerPolicy::UseFallback => {
|
||||||
probability: 0.0,
|
|
||||||
speech: false,
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
used_fallback_vad = true;
|
used_fallback_vad = true;
|
||||||
self.mark_vad_fallback_active(vad_backend);
|
self.mark_vad_fallback_active(vad_backend);
|
||||||
crate::vad::VoiceActivityDetector::process_10ms(
|
crate::vad::VoiceActivityDetector::process_10ms(
|
||||||
@@ -441,10 +414,10 @@ impl AndroidCaptureState {
|
|||||||
&frame,
|
&frame,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
} else {
|
crate::vad::VadWorkerPolicy::NotModelBacked => crate::vad::VadOutput {
|
||||||
used_fallback_vad = true;
|
probability: 1.0,
|
||||||
self.mark_vad_fallback_active(vad_backend);
|
speech: true,
|
||||||
crate::vad::VoiceActivityDetector::process_10ms(&mut self.vad_detector, &frame)
|
},
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
crate::vad::VoiceActivityDetector::process_10ms(&mut self.vad_detector, &frame)
|
crate::vad::VoiceActivityDetector::process_10ms(&mut self.vad_detector, &frame)
|
||||||
@@ -472,21 +445,19 @@ impl AndroidCaptureState {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let gain = self.mic_gain;
|
if crate::capture_accumulator::append_processed_i16_bounded(
|
||||||
if (gain - 1.0).abs() < f32::EPSILON {
|
&mut self.pcm_accum,
|
||||||
self.pcm_accum
|
&frame,
|
||||||
.extend(frame.iter().copied().map(crate::frame::f32_to_i16));
|
self.mic_gain,
|
||||||
} else {
|
) {
|
||||||
self.pcm_accum.extend(frame.iter().copied().map(|s| {
|
self.audio_processing_stats.increment_callback_xrun();
|
||||||
let scaled = (crate::frame::f32_to_i16(s) as f32) * gain;
|
|
||||||
scaled.clamp(i16::MIN as f32, i16::MAX as f32) as i16
|
|
||||||
}));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct InputCallback {
|
struct InputCallback {
|
||||||
state: Arc<Mutex<AndroidCaptureState>>,
|
state: Arc<Mutex<AndroidCaptureState>>,
|
||||||
|
audio_processing_stats: Arc<crate::SharedAudioProcessingStats>,
|
||||||
event_tx: BackendEventTx,
|
event_tx: BackendEventTx,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -498,9 +469,13 @@ impl AudioInputCallback for InputCallback {
|
|||||||
_stream: &mut dyn AudioInputStreamSafe,
|
_stream: &mut dyn AudioInputStreamSafe,
|
||||||
frames: &[i16],
|
frames: &[i16],
|
||||||
) -> DataCallbackResult {
|
) -> DataCallbackResult {
|
||||||
let _ = catch_unwind(AssertUnwindSafe(|| {
|
let _ = catch_unwind(AssertUnwindSafe(|| match self.state.try_lock() {
|
||||||
if let Ok(mut s) = self.state.lock() {
|
Ok(mut s) => s.ingest_i16(frames),
|
||||||
s.ingest_i16(frames);
|
Err(std::sync::TryLockError::WouldBlock) => {
|
||||||
|
self.audio_processing_stats.increment_callback_xrun();
|
||||||
|
}
|
||||||
|
Err(std::sync::TryLockError::Poisoned(e)) => {
|
||||||
|
warn!(target: "chanora_audio", "android: capture state poisoned: {e}");
|
||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
DataCallbackResult::Continue
|
DataCallbackResult::Continue
|
||||||
@@ -522,8 +497,7 @@ impl AudioInputCallback for InputCallback {
|
|||||||
// writes stereo f32 directly to the Oboe output buffer.
|
// writes stereo f32 directly to the Oboe output buffer.
|
||||||
|
|
||||||
struct OutputCallback {
|
struct OutputCallback {
|
||||||
handler: AudioHandler<SessionAudioId>,
|
pcm_consumer: crate::android_render_ring::AndroidRenderRingConsumer,
|
||||||
event_consumer: crate::audio_event_queue::AudioEventConsumer,
|
|
||||||
output_gain: Arc<AtomicU32>,
|
output_gain: Arc<AtomicU32>,
|
||||||
output_muted: Arc<AtomicBool>,
|
output_muted: Arc<AtomicBool>,
|
||||||
event_tx: BackendEventTx,
|
event_tx: BackendEventTx,
|
||||||
@@ -542,47 +516,39 @@ impl AudioOutputCallback for OutputCallback {
|
|||||||
frames: &mut [(f32, f32)],
|
frames: &mut [(f32, f32)],
|
||||||
) -> DataCallbackResult {
|
) -> DataCallbackResult {
|
||||||
let _ = catch_unwind(AssertUnwindSafe(|| {
|
let _ = catch_unwind(AssertUnwindSafe(|| {
|
||||||
let buf: &mut [f32] =
|
self.pcm_consumer.drain_stereo_into_zero_filling(frames);
|
||||||
bytemuck::cast_slice_mut::<(f32, f32), f32>(frames);
|
|
||||||
for s in buf.iter_mut() {
|
|
||||||
*s = 0.0;
|
|
||||||
}
|
|
||||||
for cmd in self.event_consumer.drain_controls() {
|
|
||||||
match cmd {
|
|
||||||
AudioCommand::SetVolume(id, vol) => {
|
|
||||||
if let Some(q) = self.handler.get_mut_queues().get_mut(&id) {
|
|
||||||
q.volume = vol;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
AudioCommand::RemoveClient(id) => {
|
|
||||||
self.handler.get_mut_queues().remove(&id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for pkt in self.event_consumer.drain_packets(50) {
|
|
||||||
if let Err(e) = self.handler.handle_packet(pkt.client_id, pkt.data) {
|
|
||||||
debug!(target: "chanora_audio", error = %e, "decode failed");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let _ = self.handler.fill_buffer(buf);
|
|
||||||
let gain = f32::from_bits(self.output_gain.load(Ordering::Relaxed));
|
let gain = f32::from_bits(self.output_gain.load(Ordering::Relaxed));
|
||||||
let muted = self.output_muted.load(Ordering::Relaxed);
|
let muted = self.output_muted.load(Ordering::Relaxed);
|
||||||
if muted {
|
if muted {
|
||||||
for s in buf.iter_mut() {
|
for frame in frames.iter_mut() {
|
||||||
*s = 0.0;
|
*frame = (0.0, 0.0);
|
||||||
}
|
}
|
||||||
} else if gain != 1.0 {
|
} else if gain != 1.0 {
|
||||||
for s in buf.iter_mut() {
|
for (left, right) in frames.iter_mut() {
|
||||||
*s *= gain;
|
*left *= gain;
|
||||||
|
*right *= gain;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
let mut sum_squares = 0.0_f32;
|
||||||
|
for (left, right) in frames.iter() {
|
||||||
|
sum_squares += left * left + right * right;
|
||||||
|
}
|
||||||
|
let sample_count = frames.len() * 2;
|
||||||
|
let dbfs = if sample_count == 0 {
|
||||||
|
-120.0
|
||||||
|
} else {
|
||||||
|
let rms = (sum_squares / sample_count as f32).sqrt();
|
||||||
|
if rms <= 0.000_001 {
|
||||||
|
-120.0
|
||||||
|
} else {
|
||||||
|
20.0 * rms.log10()
|
||||||
|
}
|
||||||
|
};
|
||||||
self.audio_processing_stats
|
self.audio_processing_stats
|
||||||
.update_render(crate::frame::dbfs(buf), frames.len() as u32);
|
.update_render(dbfs, frames.len() as u32);
|
||||||
|
|
||||||
for chunk in buf.chunks_exact(2) {
|
for (left, right) in frames.iter() {
|
||||||
self.pending_render_ref[self.pending_render_ref_len] = (chunk[0] + chunk[1]) * 0.5;
|
self.pending_render_ref[self.pending_render_ref_len] = (left + right) * 0.5;
|
||||||
self.pending_render_ref_len += 1;
|
self.pending_render_ref_len += 1;
|
||||||
if self.pending_render_ref_len == crate::frame::FRAME_10MS_SAMPLES {
|
if self.pending_render_ref_len == crate::frame::FRAME_10MS_SAMPLES {
|
||||||
self.render_reference.write(&self.pending_render_ref);
|
self.render_reference.write(&self.pending_render_ref);
|
||||||
@@ -614,6 +580,7 @@ impl AudioOutputCallback for OutputCallback {
|
|||||||
pub struct AndroidVoiceUnit {
|
pub struct AndroidVoiceUnit {
|
||||||
input: Option<AudioStreamAsync<OboeInput, InputCallback>>,
|
input: Option<AudioStreamAsync<OboeInput, InputCallback>>,
|
||||||
output: Option<AudioStreamAsync<OboeOutput, OutputCallback>>,
|
output: Option<AudioStreamAsync<OboeOutput, OutputCallback>>,
|
||||||
|
render_producer_shutdown: Arc<AtomicBool>,
|
||||||
|
|
||||||
// Recorded achieved values (SDD-112).
|
// Recorded achieved values (SDD-112).
|
||||||
input_perf: AchievedPerformanceMode,
|
input_perf: AchievedPerformanceMode,
|
||||||
@@ -635,11 +602,13 @@ pub struct AndroidVoiceUnit {
|
|||||||
|
|
||||||
#[derive(Default)]
|
#[derive(Default)]
|
||||||
struct HardwareEffectHandles {
|
struct HardwareEffectHandles {
|
||||||
aec: Option<jni::objects::GlobalRef>,
|
aec: Option<AndroidGlobalObject>,
|
||||||
ns: Option<jni::objects::GlobalRef>,
|
ns: Option<AndroidGlobalObject>,
|
||||||
agc: Option<jni::objects::GlobalRef>,
|
agc: Option<AndroidGlobalObject>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type AndroidGlobalObject = jni::refs::Global<jni::objects::JObject<'static>>;
|
||||||
|
|
||||||
impl AndroidVoiceUnit {
|
impl AndroidVoiceUnit {
|
||||||
/// Open the input + output streams (SDD-111 + SDD-112) and,
|
/// Open the input + output streams (SDD-111 + SDD-112) and,
|
||||||
/// once a session id is available, attach SDD-113 hardware
|
/// once a session id is available, attach SDD-113 hardware
|
||||||
@@ -706,6 +675,7 @@ impl AndroidVoiceUnit {
|
|||||||
|
|
||||||
let input_cb = InputCallback {
|
let input_cb = InputCallback {
|
||||||
state: capture_state.clone(),
|
state: capture_state.clone(),
|
||||||
|
audio_processing_stats: audio_processing_stats.clone(),
|
||||||
event_tx: event_tx.clone(),
|
event_tx: event_tx.clone(),
|
||||||
};
|
};
|
||||||
let input_builder = input_builder.set_callback(input_cb);
|
let input_builder = input_builder.set_callback(input_cb);
|
||||||
@@ -722,7 +692,12 @@ impl AndroidVoiceUnit {
|
|||||||
error = ?e,
|
error = ?e,
|
||||||
"android: primary input stream open failed; entering fallback ladder"
|
"android: primary input stream open failed; entering fallback ladder"
|
||||||
);
|
);
|
||||||
match Self::open_input_fallback(cfg, &event_tx, capture_state.clone()) {
|
match Self::open_input_fallback(
|
||||||
|
cfg,
|
||||||
|
&event_tx,
|
||||||
|
capture_state.clone(),
|
||||||
|
audio_processing_stats.clone(),
|
||||||
|
) {
|
||||||
Ok(s) => Some(s),
|
Ok(s) => Some(s),
|
||||||
Err(fallback_err) => {
|
Err(fallback_err) => {
|
||||||
warn!(
|
warn!(
|
||||||
@@ -789,9 +764,10 @@ impl AndroidVoiceUnit {
|
|||||||
|
|
||||||
let render_ref_for_output = render_ref_buf.clone();
|
let render_ref_for_output = render_ref_buf.clone();
|
||||||
let event_queue = params.event_producer.queue();
|
let event_queue = params.event_producer.queue();
|
||||||
|
let render_ring =
|
||||||
|
crate::android_render_ring::AndroidRenderRing::new(ANDROID_RENDER_RING_CAPACITY);
|
||||||
let output_cb = OutputCallback {
|
let output_cb = OutputCallback {
|
||||||
handler: params.handler,
|
pcm_consumer: render_ring.consumer(),
|
||||||
event_consumer: AudioEventQueue::consumer(&event_queue),
|
|
||||||
output_gain: params.output_gain.clone(),
|
output_gain: params.output_gain.clone(),
|
||||||
output_muted: params.output_muted.clone(),
|
output_muted: params.output_muted.clone(),
|
||||||
event_tx: event_tx.clone(),
|
event_tx: event_tx.clone(),
|
||||||
@@ -813,8 +789,7 @@ impl AndroidVoiceUnit {
|
|||||||
Self::open_output_fallback(
|
Self::open_output_fallback(
|
||||||
cfg,
|
cfg,
|
||||||
&event_tx,
|
&event_tx,
|
||||||
AudioHandler::new(),
|
render_ring.consumer(),
|
||||||
AudioEventQueue::consumer(&event_queue),
|
|
||||||
params.output_gain.clone(),
|
params.output_gain.clone(),
|
||||||
params.output_muted.clone(),
|
params.output_muted.clone(),
|
||||||
audio_processing_stats.clone(),
|
audio_processing_stats.clone(),
|
||||||
@@ -822,6 +797,11 @@ impl AndroidVoiceUnit {
|
|||||||
)?
|
)?
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
let render_producer_shutdown = Self::spawn_render_producer(
|
||||||
|
params.handler,
|
||||||
|
AudioEventQueue::consumer(&event_queue),
|
||||||
|
render_ring.producer(),
|
||||||
|
);
|
||||||
|
|
||||||
let output_frames_per_burst = output_stream.get_frames_per_burst();
|
let output_frames_per_burst = output_stream.get_frames_per_burst();
|
||||||
if output_frames_per_burst > 0 {
|
if output_frames_per_burst > 0 {
|
||||||
@@ -978,6 +958,7 @@ impl AndroidVoiceUnit {
|
|||||||
Ok(Self {
|
Ok(Self {
|
||||||
input: input_stream,
|
input: input_stream,
|
||||||
output: Some(output_stream),
|
output: Some(output_stream),
|
||||||
|
render_producer_shutdown,
|
||||||
input_perf,
|
input_perf,
|
||||||
input_share,
|
input_share,
|
||||||
output_perf,
|
output_perf,
|
||||||
@@ -995,6 +976,7 @@ impl AndroidVoiceUnit {
|
|||||||
cfg: &AndroidVoiceStreamConfig,
|
cfg: &AndroidVoiceStreamConfig,
|
||||||
event_tx: &BackendEventTx,
|
event_tx: &BackendEventTx,
|
||||||
capture_state: Arc<Mutex<AndroidCaptureState>>,
|
capture_state: Arc<Mutex<AndroidCaptureState>>,
|
||||||
|
audio_processing_stats: Arc<crate::SharedAudioProcessingStats>,
|
||||||
) -> Result<AudioStreamAsync<OboeInput, InputCallback>, BackendError> {
|
) -> Result<AudioStreamAsync<OboeInput, InputCallback>, BackendError> {
|
||||||
// SDD-112 items 6 & 7: explore (preset × sharing) independently
|
// SDD-112 items 6 & 7: explore (preset × sharing) independently
|
||||||
// via the pure helpers in `mobile_voice_backend`. Primary
|
// via the pure helpers in `mobile_voice_backend`. Primary
|
||||||
@@ -1031,6 +1013,7 @@ impl AndroidVoiceUnit {
|
|||||||
};
|
};
|
||||||
let cb = InputCallback {
|
let cb = InputCallback {
|
||||||
state: capture_state.clone(),
|
state: capture_state.clone(),
|
||||||
|
audio_processing_stats: audio_processing_stats.clone(),
|
||||||
event_tx: event_tx.clone(),
|
event_tx: event_tx.clone(),
|
||||||
};
|
};
|
||||||
let builder = AudioStreamBuilder::default()
|
let builder = AudioStreamBuilder::default()
|
||||||
@@ -1066,16 +1049,14 @@ impl AndroidVoiceUnit {
|
|||||||
fn open_output_fallback(
|
fn open_output_fallback(
|
||||||
cfg: &AndroidVoiceStreamConfig,
|
cfg: &AndroidVoiceStreamConfig,
|
||||||
event_tx: &BackendEventTx,
|
event_tx: &BackendEventTx,
|
||||||
handler: AudioHandler<SessionAudioId>,
|
pcm_consumer: crate::android_render_ring::AndroidRenderRingConsumer,
|
||||||
event_consumer: crate::audio_event_queue::AudioEventConsumer,
|
|
||||||
output_gain: Arc<AtomicU32>,
|
output_gain: Arc<AtomicU32>,
|
||||||
output_muted: Arc<AtomicBool>,
|
output_muted: Arc<AtomicBool>,
|
||||||
audio_processing_stats: Arc<crate::SharedAudioProcessingStats>,
|
audio_processing_stats: Arc<crate::SharedAudioProcessingStats>,
|
||||||
render_reference: Arc<RenderReferenceBuffer>,
|
render_reference: Arc<RenderReferenceBuffer>,
|
||||||
) -> Result<AudioStreamAsync<OboeOutput, OutputCallback>, BackendError> {
|
) -> Result<AudioStreamAsync<OboeOutput, OutputCallback>, BackendError> {
|
||||||
let cb = OutputCallback {
|
let cb = OutputCallback {
|
||||||
handler,
|
pcm_consumer,
|
||||||
event_consumer,
|
|
||||||
output_gain,
|
output_gain,
|
||||||
output_muted,
|
output_muted,
|
||||||
event_tx: event_tx.clone(),
|
event_tx: event_tx.clone(),
|
||||||
@@ -1100,6 +1081,50 @@ impl AndroidVoiceUnit {
|
|||||||
.map_err(|e| BackendError::OpenFailed(format!("output fallback: {e:?}")))
|
.map_err(|e| BackendError::OpenFailed(format!("output fallback: {e:?}")))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn spawn_render_producer(
|
||||||
|
mut handler: AudioHandler<SessionAudioId>,
|
||||||
|
event_consumer: crate::audio_event_queue::AudioEventConsumer,
|
||||||
|
pcm_producer: crate::android_render_ring::AndroidRenderRingProducer,
|
||||||
|
) -> Arc<AtomicBool> {
|
||||||
|
let shutdown = Arc::new(AtomicBool::new(false));
|
||||||
|
let shutdown_for_task = shutdown.clone();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let mut pull_scratch = vec![0.0_f32; ANDROID_RENDER_PULL_SAMPLES];
|
||||||
|
let mut interval = tokio::time::interval(std::time::Duration::from_millis(20));
|
||||||
|
interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
|
||||||
|
loop {
|
||||||
|
interval.tick().await;
|
||||||
|
if shutdown_for_task.load(Ordering::Relaxed) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
for cmd in event_consumer.drain_controls() {
|
||||||
|
match cmd {
|
||||||
|
AudioCommand::SetVolume(id, vol) => {
|
||||||
|
if let Some(q) = handler.get_mut_queues().get_mut(&id) {
|
||||||
|
q.volume = vol;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
AudioCommand::RemoveClient(id) => {
|
||||||
|
handler.get_mut_queues().remove(&id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for pkt in event_consumer.drain_packets(50) {
|
||||||
|
if let Err(e) = handler.handle_packet(pkt.client_id, pkt.data) {
|
||||||
|
debug!(target: "chanora_audio", error = %e, "decode failed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pull_scratch.fill(0.0);
|
||||||
|
let _ = handler.fill_buffer(&mut pull_scratch);
|
||||||
|
pcm_producer.push_frame_lossy(&pull_scratch);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
shutdown
|
||||||
|
}
|
||||||
|
|
||||||
/// Clone of the event sender, for JNI focus / SCO listeners
|
/// Clone of the event sender, for JNI focus / SCO listeners
|
||||||
/// registered on the engine's behalf.
|
/// registered on the engine's behalf.
|
||||||
pub fn event_sender(&self) -> BackendEventTx {
|
pub fn event_sender(&self) -> BackendEventTx {
|
||||||
@@ -1151,6 +1176,7 @@ impl MobileVoiceAudioBackend for AndroidVoiceUnit {
|
|||||||
fn close(&mut self) -> Result<(), BackendError> {
|
fn close(&mut self) -> Result<(), BackendError> {
|
||||||
// SDD-115 reverse order: release hardware effects FIRST,
|
// SDD-115 reverse order: release hardware effects FIRST,
|
||||||
// then close streams.
|
// then close streams.
|
||||||
|
self.render_producer_shutdown.store(true, Ordering::Relaxed);
|
||||||
release_hardware_effects(&mut self.hw_effects);
|
release_hardware_effects(&mut self.hw_effects);
|
||||||
self.stop().ok();
|
self.stop().ok();
|
||||||
// Dropping the Option drops the underlying AudioStreamAsync
|
// Dropping the Option drops the underlying AudioStreamAsync
|
||||||
@@ -1208,6 +1234,7 @@ impl Drop for AndroidVoiceUnit {
|
|||||||
// Wrap in catch_unwind so a panic during Drop cannot unwind
|
// Wrap in catch_unwind so a panic during Drop cannot unwind
|
||||||
// into the JVM (SDD-115 callback safety).
|
// into the JVM (SDD-115 callback safety).
|
||||||
let _ = catch_unwind(AssertUnwindSafe(|| {
|
let _ = catch_unwind(AssertUnwindSafe(|| {
|
||||||
|
self.render_producer_shutdown.store(true, Ordering::Relaxed);
|
||||||
release_hardware_effects(&mut self.hw_effects);
|
release_hardware_effects(&mut self.hw_effects);
|
||||||
// SDD-116: clear the diagnostics slot on Drop too.
|
// SDD-116: clear the diagnostics slot on Drop too.
|
||||||
clear_android_audio_diagnostics();
|
clear_android_audio_diagnostics();
|
||||||
@@ -1287,62 +1314,71 @@ fn attach_hardware_effects_inner(
|
|||||||
session_id: AudioSessionId,
|
session_id: AudioSessionId,
|
||||||
effects: &crate::AudioEffects,
|
effects: &crate::AudioEffects,
|
||||||
) -> HardwareEffectHandles {
|
) -> HardwareEffectHandles {
|
||||||
|
with_android_env("hardware effects", |env| {
|
||||||
|
let mut handles = HardwareEffectHandles::default();
|
||||||
|
if effects.aec {
|
||||||
|
handles.aec = create_effect(
|
||||||
|
env,
|
||||||
|
"android/media/audiofx/AcousticEchoCanceler",
|
||||||
|
session_id,
|
||||||
|
"AEC",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if effects.noise_suppression {
|
||||||
|
handles.ns = create_effect(
|
||||||
|
env,
|
||||||
|
"android/media/audiofx/NoiseSuppressor",
|
||||||
|
session_id,
|
||||||
|
"NS",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if effects.agc {
|
||||||
|
handles.agc = create_effect(
|
||||||
|
env,
|
||||||
|
"android/media/audiofx/AutomaticGainControl",
|
||||||
|
session_id,
|
||||||
|
"AGC",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
handles
|
||||||
|
})
|
||||||
|
.unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn with_android_env<R>(
|
||||||
|
operation: &str,
|
||||||
|
op: impl for<'local> FnOnce(&mut jni::Env<'local>) -> R,
|
||||||
|
) -> Option<R> {
|
||||||
let ctx = ndk_context::android_context();
|
let ctx = ndk_context::android_context();
|
||||||
if ctx.vm().is_null() {
|
if ctx.vm().is_null() {
|
||||||
warn!(
|
warn!(
|
||||||
target: "chanora_audio",
|
target: "chanora_audio",
|
||||||
"android: ndk_context vm null; cannot bind hardware effects (software fallback engages)"
|
operation,
|
||||||
|
"android: ndk_context vm null; JNI call skipped"
|
||||||
);
|
);
|
||||||
return HardwareEffectHandles::default();
|
return None;
|
||||||
}
|
}
|
||||||
let jvm = match unsafe { jni::JavaVM::from_raw(ctx.vm() as *mut _) } {
|
|
||||||
Ok(v) => v,
|
|
||||||
Err(e) => {
|
|
||||||
warn!(target: "chanora_audio", error = %e, "android: JavaVM::from_raw failed; effects not bound");
|
|
||||||
return HardwareEffectHandles::default();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
let mut env = match jvm.attach_current_thread() {
|
|
||||||
Ok(e) => e,
|
|
||||||
Err(e) => {
|
|
||||||
warn!(target: "chanora_audio", error = %e, "android: attach_current_thread failed; effects not bound");
|
|
||||||
return HardwareEffectHandles::default();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let mut handles = HardwareEffectHandles::default();
|
let jvm = unsafe { jni::JavaVM::from_raw(ctx.vm() as *mut _) };
|
||||||
if effects.aec {
|
match jvm.attach_current_thread(|env| Ok::<R, jni::errors::Error>(op(env))) {
|
||||||
handles.aec = create_effect(
|
Ok(value) => Some(value),
|
||||||
&mut env,
|
Err(e) => {
|
||||||
"android/media/audiofx/AcousticEchoCanceler",
|
warn!(target: "chanora_audio", error = %e, operation, "android: attach_current_thread failed");
|
||||||
session_id,
|
None
|
||||||
"AEC",
|
}
|
||||||
);
|
|
||||||
}
|
}
|
||||||
if effects.noise_suppression {
|
|
||||||
handles.ns = create_effect(
|
|
||||||
&mut env,
|
|
||||||
"android/media/audiofx/NoiseSuppressor",
|
|
||||||
session_id,
|
|
||||||
"NS",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if effects.agc {
|
|
||||||
handles.agc = create_effect(
|
|
||||||
&mut env,
|
|
||||||
"android/media/audiofx/AutomaticGainControl",
|
|
||||||
session_id,
|
|
||||||
"AGC",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
handles
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// SDD-113 item 3: probe the static `isAvailable()` on each effect
|
/// SDD-113 item 3: probe the static `isAvailable()` on each effect
|
||||||
/// class before calling `create(int)`. Returns `false` on any JNI
|
/// class before calling `create(int)`. Returns `false` on any JNI
|
||||||
/// failure so the caller engages the software fallback.
|
/// failure so the caller engages the software fallback.
|
||||||
fn effect_is_available(env: &mut jni::JNIEnv, class: &jni::objects::JClass, label: &str) -> bool {
|
fn effect_is_available(env: &mut jni::Env<'_>, class: &jni::objects::JClass, label: &str) -> bool {
|
||||||
match env.call_static_method(class, "isAvailable", "()Z", &[]) {
|
match env.call_static_method(
|
||||||
|
class,
|
||||||
|
jni::jni_str!("isAvailable"),
|
||||||
|
jni::jni_sig!("()Z"),
|
||||||
|
&[],
|
||||||
|
) {
|
||||||
Ok(v) => match v.z() {
|
Ok(v) => match v.z() {
|
||||||
Ok(b) => b,
|
Ok(b) => b,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
@@ -1360,14 +1396,14 @@ fn effect_is_available(env: &mut jni::JNIEnv, class: &jni::objects::JClass, labe
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn create_effect(
|
fn create_effect(
|
||||||
env: &mut jni::JNIEnv,
|
env: &mut jni::Env<'_>,
|
||||||
fqcn: &str,
|
fqcn: &str,
|
||||||
session_id: AudioSessionId,
|
session_id: AudioSessionId,
|
||||||
label: &str,
|
label: &str,
|
||||||
) -> Option<jni::objects::GlobalRef> {
|
) -> Option<AndroidGlobalObject> {
|
||||||
use jni::objects::JValue;
|
use jni::objects::JValue;
|
||||||
// Class.create(int) -> ClassInstance|null
|
// Class.create(int) -> ClassInstance|null
|
||||||
let class = match env.find_class(fqcn) {
|
let class = match env.find_class(jni::strings::JNIString::new(fqcn)) {
|
||||||
Ok(c) => c,
|
Ok(c) => c,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
warn!(target: "chanora_audio", error = %e, effect = label, "android: find_class failed; effect not bound — software fallback engages");
|
warn!(target: "chanora_audio", error = %e, effect = label, "android: find_class failed; effect not bound — software fallback engages");
|
||||||
@@ -1383,10 +1419,18 @@ fn create_effect(
|
|||||||
);
|
);
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
|
let create_sig = match jni::signature::RuntimeMethodSignature::from_str(format!("(I)L{fqcn};"))
|
||||||
|
{
|
||||||
|
Ok(sig) => sig,
|
||||||
|
Err(e) => {
|
||||||
|
warn!(target: "chanora_audio", error = %e, effect = label, "android: create() signature parse failed");
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
};
|
||||||
let inst = match env.call_static_method(
|
let inst = match env.call_static_method(
|
||||||
&class,
|
&class,
|
||||||
"create",
|
jni::jni_str!("create"),
|
||||||
&format!("(I)L{fqcn};"),
|
create_sig.method_signature(),
|
||||||
&[JValue::Int(session_id)],
|
&[JValue::Int(session_id)],
|
||||||
) {
|
) {
|
||||||
Ok(v) => match v.l() {
|
Ok(v) => match v.l() {
|
||||||
@@ -1411,8 +1455,8 @@ fn create_effect(
|
|||||||
// setEnabled(true) -> int (success code)
|
// setEnabled(true) -> int (success code)
|
||||||
if let Err(e) = env.call_method(
|
if let Err(e) = env.call_method(
|
||||||
&inst,
|
&inst,
|
||||||
"setEnabled",
|
jni::jni_str!("setEnabled"),
|
||||||
"(Z)I",
|
jni::jni_sig!("(Z)I"),
|
||||||
&[JValue::Bool(jni::sys::JNI_TRUE)],
|
&[JValue::Bool(jni::sys::JNI_TRUE)],
|
||||||
) {
|
) {
|
||||||
let _ = env.exception_clear();
|
let _ = env.exception_clear();
|
||||||
@@ -1448,34 +1492,28 @@ fn release_hardware_effects_inner(handles: &mut HardwareEffectHandles) {
|
|||||||
if aec.is_none() && ns.is_none() && agc.is_none() {
|
if aec.is_none() && ns.is_none() && agc.is_none() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let ctx = ndk_context::android_context();
|
let _ = with_android_env("release hardware effects", |env| {
|
||||||
if ctx.vm().is_null() {
|
for (effect, label) in [(aec, "AEC"), (ns, "NS"), (agc, "AGC")] {
|
||||||
return;
|
if let Some(g) = effect {
|
||||||
}
|
let _ = env.call_method(
|
||||||
// SAFETY: vm is non-null and owned for process lifetime via JNI_OnLoad.
|
g.as_obj(),
|
||||||
let jvm = match unsafe { jni::JavaVM::from_raw(ctx.vm() as *mut _) } {
|
jni::jni_str!("setEnabled"),
|
||||||
Ok(v) => v,
|
jni::jni_sig!("(Z)I"),
|
||||||
Err(_) => return,
|
&[jni::objects::JValue::Bool(jni::sys::JNI_FALSE)],
|
||||||
};
|
);
|
||||||
let mut env = match jvm.attach_current_thread() {
|
env.exception_clear();
|
||||||
Ok(e) => e,
|
let _ = env.call_method(
|
||||||
Err(_) => return,
|
g.as_obj(),
|
||||||
};
|
jni::jni_str!("release"),
|
||||||
for (effect, label) in [(aec, "AEC"), (ns, "NS"), (agc, "AGC")] {
|
jni::jni_sig!("()V"),
|
||||||
if let Some(g) = effect {
|
&[],
|
||||||
let _ = env.call_method(
|
);
|
||||||
g.as_obj(),
|
env.exception_clear();
|
||||||
"setEnabled",
|
drop(g);
|
||||||
"(Z)I",
|
info!(target: "chanora_audio", effect = label, "android: hardware effect released");
|
||||||
&[jni::objects::JValue::Bool(jni::sys::JNI_FALSE)],
|
}
|
||||||
);
|
|
||||||
let _ = env.exception_clear();
|
|
||||||
let _ = env.call_method(g.as_obj(), "release", "()V", &[]);
|
|
||||||
let _ = env.exception_clear();
|
|
||||||
drop(g);
|
|
||||||
info!(target: "chanora_audio", effect = label, "android: hardware effect released");
|
|
||||||
}
|
}
|
||||||
}
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Process-global BackendEvent sender for JNI callbacks --------
|
// --- Process-global BackendEvent sender for JNI callbacks --------
|
||||||
@@ -1550,7 +1588,7 @@ pub fn chanora_android_stop_voice_service() -> bool {
|
|||||||
fn call_voice_service_static(method: &str) -> bool {
|
fn call_voice_service_static(method: &str) -> bool {
|
||||||
use jni::objects::{JObject, JValue};
|
use jni::objects::{JObject, JValue};
|
||||||
let ctx = ndk_context::android_context();
|
let ctx = ndk_context::android_context();
|
||||||
if ctx.vm().is_null() || ctx.context().is_null() {
|
if ctx.context().is_null() {
|
||||||
warn!(
|
warn!(
|
||||||
target: "chanora_audio",
|
target: "chanora_audio",
|
||||||
method,
|
method,
|
||||||
@@ -1558,54 +1596,41 @@ fn call_voice_service_static(method: &str) -> bool {
|
|||||||
);
|
);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
// SAFETY: vm/context populated by chanora_bridge::android_init at
|
|
||||||
// JNI_OnLoad + initChanoraContext; both pointers are valid for
|
with_android_env("voice foreground service", |env| {
|
||||||
// the process lifetime.
|
// SAFETY: ndk_context::context() is the application Context
|
||||||
let jvm = match unsafe { jni::JavaVM::from_raw(ctx.vm() as *mut _) } {
|
// jobject; valid global ref for process lifetime.
|
||||||
Ok(v) => v,
|
let context_obj = unsafe { JObject::from_raw(env, ctx.context() as jni::sys::jobject) };
|
||||||
Err(e) => {
|
let class = match load_app_class(env, &context_obj, ANDROID_VOICE_FG_SERVICE_FQCN) {
|
||||||
warn!(target: "chanora_audio", error = %e, method, "android: JavaVM::from_raw failed");
|
Some(c) => c,
|
||||||
return false;
|
None => return false,
|
||||||
|
};
|
||||||
|
match env.call_static_method(
|
||||||
|
&class,
|
||||||
|
jni::strings::JNIString::new(method),
|
||||||
|
jni::jni_sig!("(Landroid/content/Context;)V"),
|
||||||
|
&[JValue::Object(&context_obj)],
|
||||||
|
) {
|
||||||
|
Ok(_) => {
|
||||||
|
info!(target: "chanora_audio", method, "android: voice foreground service call dispatched");
|
||||||
|
true
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
env.exception_clear();
|
||||||
|
warn!(target: "chanora_audio", error = %e, method, "android: foreground service static call failed");
|
||||||
|
false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
})
|
||||||
let mut env = match jvm.attach_current_thread() {
|
.unwrap_or(false)
|
||||||
Ok(e) => e,
|
|
||||||
Err(e) => {
|
|
||||||
warn!(target: "chanora_audio", error = %e, method, "android: attach_current_thread failed");
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
// SAFETY: ndk_context::context() is the application Context
|
|
||||||
// jobject; valid global ref for process lifetime.
|
|
||||||
let context_obj = unsafe { JObject::from_raw(ctx.context() as jni::sys::jobject) };
|
|
||||||
let class = match load_app_class(&mut env, &context_obj, ANDROID_VOICE_FG_SERVICE_FQCN) {
|
|
||||||
Some(c) => c,
|
|
||||||
None => return false,
|
|
||||||
};
|
|
||||||
match env.call_static_method(
|
|
||||||
&class,
|
|
||||||
method,
|
|
||||||
"(Landroid/content/Context;)V",
|
|
||||||
&[JValue::Object(&context_obj)],
|
|
||||||
) {
|
|
||||||
Ok(_) => {
|
|
||||||
info!(target: "chanora_audio", method, "android: voice foreground service call dispatched");
|
|
||||||
true
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
let _ = env.exception_clear();
|
|
||||||
warn!(target: "chanora_audio", error = %e, method, "android: foreground service static call failed");
|
|
||||||
false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn load_app_class<'local>(
|
fn load_app_class<'local>(
|
||||||
env: &mut jni::JNIEnv<'local>,
|
env: &mut jni::Env<'local>,
|
||||||
context_obj: &jni::objects::JObject<'local>,
|
context_obj: &jni::objects::JObject<'local>,
|
||||||
slash_name: &str,
|
slash_name: &str,
|
||||||
) -> Option<jni::objects::JClass<'local>> {
|
) -> Option<jni::objects::JClass<'local>> {
|
||||||
match env.find_class(slash_name) {
|
match env.find_class(jni::strings::JNIString::new(slash_name)) {
|
||||||
Ok(c) => return Some(c),
|
Ok(c) => return Some(c),
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
let _ = env.exception_clear();
|
let _ = env.exception_clear();
|
||||||
@@ -1616,8 +1641,8 @@ fn load_app_class<'local>(
|
|||||||
let loader = match env
|
let loader = match env
|
||||||
.call_method(
|
.call_method(
|
||||||
context_obj,
|
context_obj,
|
||||||
"getClassLoader",
|
jni::jni_str!("getClassLoader"),
|
||||||
"()Ljava/lang/ClassLoader;",
|
jni::jni_sig!("()Ljava/lang/ClassLoader;"),
|
||||||
&[],
|
&[],
|
||||||
)
|
)
|
||||||
.and_then(|v| v.l())
|
.and_then(|v| v.l())
|
||||||
@@ -1642,13 +1667,20 @@ fn load_app_class<'local>(
|
|||||||
match env
|
match env
|
||||||
.call_method(
|
.call_method(
|
||||||
&loader,
|
&loader,
|
||||||
"loadClass",
|
jni::jni_str!("loadClass"),
|
||||||
"(Ljava/lang/String;)Ljava/lang/Class;",
|
jni::jni_sig!("(Ljava/lang/String;)Ljava/lang/Class;"),
|
||||||
&[jni::objects::JValue::Object(&class_name_obj)],
|
&[jni::objects::JValue::Object(&class_name_obj)],
|
||||||
)
|
)
|
||||||
.and_then(|v| v.l())
|
.and_then(|v| v.l())
|
||||||
{
|
{
|
||||||
Ok(class_obj) => Some(jni::objects::JClass::from(class_obj)),
|
Ok(class_obj) => match env.cast_local::<jni::objects::JClass>(class_obj) {
|
||||||
|
Ok(class) => Some(class),
|
||||||
|
Err(e) => {
|
||||||
|
env.exception_clear();
|
||||||
|
warn!(target: "chanora_audio", error = %e, class = %dotted_name, "android: ClassLoader.loadClass returned non-Class object");
|
||||||
|
None
|
||||||
|
}
|
||||||
|
},
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
let _ = env.exception_clear();
|
let _ = env.exception_clear();
|
||||||
warn!(target: "chanora_audio", error = %e, class = %dotted_name, "android: ClassLoader.loadClass failed");
|
warn!(target: "chanora_audio", error = %e, class = %dotted_name, "android: ClassLoader.loadClass failed");
|
||||||
@@ -1679,7 +1711,7 @@ fn load_app_class<'local>(
|
|||||||
pub extern "system" fn Java_app_chanora_chanora_1flutter_AndroidAudioFocusController_publishFocusChange<
|
pub extern "system" fn Java_app_chanora_chanora_1flutter_AndroidAudioFocusController_publishFocusChange<
|
||||||
'local,
|
'local,
|
||||||
>(
|
>(
|
||||||
_env: jni::JNIEnv<'local>,
|
_env: jni::EnvUnowned<'local>,
|
||||||
_class: jni::objects::JClass<'local>,
|
_class: jni::objects::JClass<'local>,
|
||||||
state: jni::sys::jint,
|
state: jni::sys::jint,
|
||||||
) {
|
) {
|
||||||
@@ -1717,7 +1749,7 @@ pub extern "system" fn Java_app_chanora_chanora_1flutter_AndroidAudioFocusContro
|
|||||||
pub extern "system" fn Java_app_chanora_chanora_1flutter_AndroidBluetoothScoController_publishScoStateChange<
|
pub extern "system" fn Java_app_chanora_chanora_1flutter_AndroidBluetoothScoController_publishScoStateChange<
|
||||||
'local,
|
'local,
|
||||||
>(
|
>(
|
||||||
_env: jni::JNIEnv<'local>,
|
_env: jni::EnvUnowned<'local>,
|
||||||
_class: jni::objects::JClass<'local>,
|
_class: jni::objects::JClass<'local>,
|
||||||
state: jni::sys::jint,
|
state: jni::sys::jint,
|
||||||
) {
|
) {
|
||||||
@@ -1766,7 +1798,7 @@ pub fn chanora_android_stop_bluetooth_sco() -> bool {
|
|||||||
fn call_static_void_context(fqcn: &str, method: &str) -> bool {
|
fn call_static_void_context(fqcn: &str, method: &str) -> bool {
|
||||||
use jni::objects::{JObject, JValue};
|
use jni::objects::{JObject, JValue};
|
||||||
let ctx = ndk_context::android_context();
|
let ctx = ndk_context::android_context();
|
||||||
if ctx.vm().is_null() || ctx.context().is_null() {
|
if ctx.context().is_null() {
|
||||||
warn!(
|
warn!(
|
||||||
target: "chanora_audio",
|
target: "chanora_audio",
|
||||||
class = fqcn,
|
class = fqcn,
|
||||||
@@ -1775,39 +1807,29 @@ fn call_static_void_context(fqcn: &str, method: &str) -> bool {
|
|||||||
);
|
);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
let jvm = match unsafe { jni::JavaVM::from_raw(ctx.vm() as *mut _) } {
|
|
||||||
Ok(v) => v,
|
with_android_env("static context call", |env| {
|
||||||
Err(e) => {
|
let context_obj = unsafe { JObject::from_raw(env, ctx.context() as jni::sys::jobject) };
|
||||||
warn!(target: "chanora_audio", error = %e, class = fqcn, method, "android: JavaVM::from_raw failed");
|
let class = match load_app_class(env, &context_obj, fqcn) {
|
||||||
return false;
|
Some(c) => c,
|
||||||
|
None => return false,
|
||||||
|
};
|
||||||
|
match env.call_static_method(
|
||||||
|
&class,
|
||||||
|
jni::strings::JNIString::new(method),
|
||||||
|
jni::jni_sig!("(Landroid/content/Context;)V"),
|
||||||
|
&[JValue::Object(&context_obj)],
|
||||||
|
) {
|
||||||
|
Ok(_) => {
|
||||||
|
info!(target: "chanora_audio", class = fqcn, method, "android: dispatched");
|
||||||
|
true
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
env.exception_clear();
|
||||||
|
warn!(target: "chanora_audio", error = %e, class = fqcn, method, "android: static call failed");
|
||||||
|
false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
})
|
||||||
let mut env = match jvm.attach_current_thread() {
|
.unwrap_or(false)
|
||||||
Ok(e) => e,
|
|
||||||
Err(e) => {
|
|
||||||
warn!(target: "chanora_audio", error = %e, class = fqcn, method, "android: attach_current_thread failed");
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
let context_obj = unsafe { JObject::from_raw(ctx.context() as jni::sys::jobject) };
|
|
||||||
let class = match load_app_class(&mut env, &context_obj, fqcn) {
|
|
||||||
Some(c) => c,
|
|
||||||
None => return false,
|
|
||||||
};
|
|
||||||
match env.call_static_method(
|
|
||||||
&class,
|
|
||||||
method,
|
|
||||||
"(Landroid/content/Context;)V",
|
|
||||||
&[JValue::Object(&context_obj)],
|
|
||||||
) {
|
|
||||||
Ok(_) => {
|
|
||||||
info!(target: "chanora_audio", class = fqcn, method, "android: dispatched");
|
|
||||||
true
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
let _ = env.exception_clear();
|
|
||||||
warn!(target: "chanora_audio", error = %e, class = fqcn, method, "android: static call failed");
|
|
||||||
false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,118 @@
|
|||||||
|
pub(crate) fn append_processed_i16_bounded(
|
||||||
|
pcm_accum: &mut Vec<i16>,
|
||||||
|
frame: &[f32],
|
||||||
|
gain: f32,
|
||||||
|
) -> bool {
|
||||||
|
if (gain - 1.0).abs() < f32::EPSILON {
|
||||||
|
for src in frame.iter().copied() {
|
||||||
|
if pcm_accum.len() == pcm_accum.capacity() {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
pcm_accum.push(crate::frame::f32_to_i16(src));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
for src in frame.iter().copied() {
|
||||||
|
if pcm_accum.len() == pcm_accum.capacity() {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
let scaled = (crate::frame::f32_to_i16(src) as f32) * gain;
|
||||||
|
pcm_accum.push(scaled.clamp(i16::MIN as f32, i16::MAX as f32) as i16);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn append_i16_bounded(pcm_accum: &mut Vec<i16>, frame: &[i16]) -> bool {
|
||||||
|
for src in frame.iter().copied() {
|
||||||
|
if pcm_accum.len() == pcm_accum.capacity() {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
pcm_accum.push(src);
|
||||||
|
}
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::{append_i16_bounded, append_processed_i16_bounded};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn append_processed_i16_bounded_does_not_grow_when_full() {
|
||||||
|
let frame = [0.25_f32; crate::frame::FRAME_10MS_SAMPLES];
|
||||||
|
let mut accum = Vec::with_capacity(crate::frame::FRAME_10MS_SAMPLES / 2);
|
||||||
|
let warmed_capacity = accum.capacity();
|
||||||
|
let warmed_ptr = accum.as_ptr();
|
||||||
|
|
||||||
|
let dropped = append_processed_i16_bounded(&mut accum, &frame, 1.0);
|
||||||
|
|
||||||
|
assert!(dropped);
|
||||||
|
assert_eq!(accum.len(), warmed_capacity);
|
||||||
|
assert_eq!(accum.capacity(), warmed_capacity);
|
||||||
|
assert_eq!(accum.as_ptr(), warmed_ptr);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn append_processed_i16_bounded_preserves_expected_10ms_append() {
|
||||||
|
let frame = [0.25_f32; crate::frame::FRAME_10MS_SAMPLES];
|
||||||
|
let mut accum = Vec::with_capacity(crate::frame::FRAME_20MS_SAMPLES * 2);
|
||||||
|
let warmed_capacity = accum.capacity();
|
||||||
|
let warmed_ptr = accum.as_ptr();
|
||||||
|
|
||||||
|
let dropped = append_processed_i16_bounded(&mut accum, &frame, 1.0);
|
||||||
|
|
||||||
|
assert!(!dropped);
|
||||||
|
assert_eq!(accum.len(), crate::frame::FRAME_10MS_SAMPLES);
|
||||||
|
assert_eq!(accum.capacity(), warmed_capacity);
|
||||||
|
assert_eq!(accum.as_ptr(), warmed_ptr);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn append_i16_bounded_does_not_grow_when_preroll_exceeds_capacity() {
|
||||||
|
let frame = [7_i16; crate::frame::FRAME_10MS_SAMPLES];
|
||||||
|
let mut accum = Vec::with_capacity(crate::frame::FRAME_10MS_SAMPLES / 2);
|
||||||
|
let warmed_capacity = accum.capacity();
|
||||||
|
let warmed_ptr = accum.as_ptr();
|
||||||
|
|
||||||
|
let dropped = append_i16_bounded(&mut accum, &frame);
|
||||||
|
|
||||||
|
assert!(dropped);
|
||||||
|
assert_eq!(accum.len(), warmed_capacity);
|
||||||
|
assert_eq!(accum.capacity(), warmed_capacity);
|
||||||
|
assert_eq!(accum.as_ptr(), warmed_ptr);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn append_i16_bounded_preserves_expected_10ms_append() {
|
||||||
|
let frame = [7_i16; crate::frame::FRAME_10MS_SAMPLES];
|
||||||
|
let mut accum = Vec::with_capacity(crate::frame::FRAME_20MS_SAMPLES * 2);
|
||||||
|
let warmed_capacity = accum.capacity();
|
||||||
|
let warmed_ptr = accum.as_ptr();
|
||||||
|
|
||||||
|
let dropped = append_i16_bounded(&mut accum, &frame);
|
||||||
|
|
||||||
|
assert!(!dropped);
|
||||||
|
assert_eq!(accum.len(), crate::frame::FRAME_10MS_SAMPLES);
|
||||||
|
assert_eq!(accum.capacity(), warmed_capacity);
|
||||||
|
assert_eq!(accum.as_ptr(), warmed_ptr);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn append_i16_bounded_preserves_full_vad_preroll_window() {
|
||||||
|
let frame = [7_i16; crate::frame::FRAME_10MS_SAMPLES];
|
||||||
|
let mut accum = Vec::with_capacity(crate::frame::FRAME_10MS_SAMPLES * 16);
|
||||||
|
let warmed_capacity = accum.capacity();
|
||||||
|
let warmed_ptr = accum.as_ptr();
|
||||||
|
|
||||||
|
for _ in 0..16 {
|
||||||
|
assert!(!append_i16_bounded(&mut accum, &frame));
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_eq!(accum.len(), crate::frame::FRAME_10MS_SAMPLES * 16);
|
||||||
|
assert_eq!(accum.capacity(), warmed_capacity);
|
||||||
|
assert_eq!(accum.as_ptr(), warmed_ptr);
|
||||||
|
assert!(append_i16_bounded(&mut accum, &frame));
|
||||||
|
assert_eq!(accum.len(), warmed_capacity);
|
||||||
|
assert_eq!(accum.capacity(), warmed_capacity);
|
||||||
|
assert_eq!(accum.as_ptr(), warmed_ptr);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
pub(crate) struct CaptureResampleResult {
|
||||||
|
pub(crate) output_len: usize,
|
||||||
|
pub(crate) dropped: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn resample_capture_to_48k(
|
||||||
|
samples: &[i16],
|
||||||
|
input_sample_rate_hz: u32,
|
||||||
|
resample_pos: &mut f64,
|
||||||
|
resample_last: &mut i16,
|
||||||
|
scratch: &mut Vec<i16>,
|
||||||
|
) -> CaptureResampleResult {
|
||||||
|
scratch.clear();
|
||||||
|
if samples.is_empty() {
|
||||||
|
return CaptureResampleResult {
|
||||||
|
output_len: 0,
|
||||||
|
dropped: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let ratio = input_sample_rate_hz.max(1) as f64 / crate::frame::SAMPLE_RATE_HZ as f64;
|
||||||
|
let mut pos = *resample_pos;
|
||||||
|
let mut dropped = false;
|
||||||
|
while pos < samples.len() as f64 {
|
||||||
|
let i = pos.floor() as isize;
|
||||||
|
let frac = pos - i as f64;
|
||||||
|
let a = if i <= 0 {
|
||||||
|
*resample_last as f64
|
||||||
|
} else {
|
||||||
|
samples[(i - 1) as usize] as f64
|
||||||
|
};
|
||||||
|
let b = if i < samples.len() as isize {
|
||||||
|
samples[i as usize] as f64
|
||||||
|
} else {
|
||||||
|
a
|
||||||
|
};
|
||||||
|
let value = (a + frac * (b - a))
|
||||||
|
.round()
|
||||||
|
.clamp(i16::MIN as f64, i16::MAX as f64) as i16;
|
||||||
|
if scratch.len() < scratch.capacity() {
|
||||||
|
scratch.push(value);
|
||||||
|
} else {
|
||||||
|
dropped = true;
|
||||||
|
}
|
||||||
|
pos += ratio;
|
||||||
|
}
|
||||||
|
*resample_pos = pos - samples.len() as f64;
|
||||||
|
*resample_last = *samples.last().unwrap_or(resample_last);
|
||||||
|
CaptureResampleResult {
|
||||||
|
output_len: scratch.len(),
|
||||||
|
dropped,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod android_voice_unit_resampler_tests {
|
||||||
|
use super::resample_capture_to_48k;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn android_voice_unit_resampler_reuses_scratch_without_capacity_growth() {
|
||||||
|
let samples: Vec<i16> = (0..882).map(|i| i as i16).collect();
|
||||||
|
let mut pos = 0.0;
|
||||||
|
let mut last = 0_i16;
|
||||||
|
let mut scratch = Vec::with_capacity(960);
|
||||||
|
|
||||||
|
let first = resample_capture_to_48k(&samples, 44_100, &mut pos, &mut last, &mut scratch);
|
||||||
|
let first_len = first.output_len;
|
||||||
|
assert_eq!(first_len, 960);
|
||||||
|
assert!(!first.dropped);
|
||||||
|
assert_eq!(scratch.len(), first_len);
|
||||||
|
let warmed_capacity = scratch.capacity();
|
||||||
|
let warmed_ptr = scratch.as_ptr();
|
||||||
|
|
||||||
|
for _ in 0..8 {
|
||||||
|
let result =
|
||||||
|
resample_capture_to_48k(&samples, 44_100, &mut pos, &mut last, &mut scratch);
|
||||||
|
let len = result.output_len;
|
||||||
|
assert_eq!(len, scratch.len());
|
||||||
|
assert!(len >= 959 && len <= 960);
|
||||||
|
assert!(!result.dropped);
|
||||||
|
assert_eq!(scratch.capacity(), warmed_capacity);
|
||||||
|
assert_eq!(scratch.as_ptr(), warmed_ptr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn android_voice_unit_resampler_truncates_oversized_burst_without_capacity_growth() {
|
||||||
|
let samples: Vec<i16> = (0..4_800).map(|i| i as i16).collect();
|
||||||
|
let mut pos = 0.0;
|
||||||
|
let mut last = 0_i16;
|
||||||
|
let mut scratch = Vec::with_capacity(960);
|
||||||
|
let warmed_capacity = scratch.capacity();
|
||||||
|
let warmed_ptr = scratch.as_ptr();
|
||||||
|
|
||||||
|
let result = resample_capture_to_48k(&samples, 48_000, &mut pos, &mut last, &mut scratch);
|
||||||
|
|
||||||
|
assert_eq!(result.output_len, warmed_capacity);
|
||||||
|
assert!(result.dropped);
|
||||||
|
assert_eq!(scratch.len(), warmed_capacity);
|
||||||
|
assert_eq!(scratch.capacity(), warmed_capacity);
|
||||||
|
assert_eq!(scratch.as_ptr(), warmed_ptr);
|
||||||
|
assert_eq!(pos, 0.0);
|
||||||
|
assert_eq!(last, *samples.last().unwrap());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -314,6 +314,15 @@ mod tests {
|
|||||||
rec.stop();
|
rec.stop();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ios_raw_debug_wav_does_not_push_from_realtime_callback() {
|
||||||
|
let src = include_str!("ios_raw_unit.rs");
|
||||||
|
assert!(
|
||||||
|
!src.contains("push_raw_mic") && !src.contains("push_processed_mic"),
|
||||||
|
"ios raw callbacks must not call WavDebugRecorder push_*_mic until it has a preallocated handoff"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn wav_header_is_44_bytes() {
|
fn wav_header_is_44_bytes() {
|
||||||
// Write to a temp file to test the header.
|
// Write to a temp file to test the header.
|
||||||
|
|||||||
@@ -981,7 +981,6 @@ impl AudioEngine {
|
|||||||
|
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
transmit_gate,
|
transmit_gate,
|
||||||
frames_sent,
|
|
||||||
frames_received,
|
frames_received,
|
||||||
output_gain,
|
output_gain,
|
||||||
output_muted,
|
output_muted,
|
||||||
@@ -1085,11 +1084,25 @@ impl AudioEngine {
|
|||||||
audio_processing_stats: audio_processing_stats.clone(),
|
audio_processing_stats: audio_processing_stats.clone(),
|
||||||
};
|
};
|
||||||
let mut android_voice_unit =
|
let mut android_voice_unit =
|
||||||
crate::android_voice_unit::AndroidVoiceUnit::open(&cfg_av, params).map_err(|e| {
|
match crate::android_voice_unit::AndroidVoiceUnit::open(&cfg_av, params) {
|
||||||
AudioError::Backend(format!("android: failed to open Oboe voice unit: {e}"))
|
Ok(unit) => unit,
|
||||||
})?;
|
Err(e) => {
|
||||||
|
Self::rollback_android_startup_resources(&mut audio_mode_stack);
|
||||||
|
return Err(AudioError::Backend(format!(
|
||||||
|
"android: failed to open Oboe voice unit: {e}"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
if let Err(e) = android_voice_unit.start() {
|
if let Err(e) = android_voice_unit.start() {
|
||||||
|
if let Err(close_err) = android_voice_unit.close() {
|
||||||
|
warn!(
|
||||||
|
target: "chanora_audio",
|
||||||
|
error = %close_err,
|
||||||
|
"android: AndroidVoiceUnit::close failed during startup rollback"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Self::rollback_android_startup_resources(&mut audio_mode_stack);
|
||||||
return Err(AudioError::Backend(format!(
|
return Err(AudioError::Backend(format!(
|
||||||
"android: failed to start Oboe voice unit: {e}"
|
"android: failed to start Oboe voice unit: {e}"
|
||||||
)));
|
)));
|
||||||
@@ -1326,6 +1339,43 @@ impl AudioEngine {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "android")]
|
||||||
|
fn rollback_android_startup_resources(audio_mode_stack: &mut crate::mode_stack::ModeStack) {
|
||||||
|
crate::android_voice_unit::chanora_android_stop_bluetooth_sco();
|
||||||
|
crate::android_voice_unit::chanora_android_abandon_audio_focus();
|
||||||
|
match release_android_audio_mode_for_startup_rollback(audio_mode_stack) {
|
||||||
|
crate::mode_stack::ModeRelease::LastRelease { prior } => {
|
||||||
|
match android_set_audio_mode(prior) {
|
||||||
|
Ok(()) => info!(
|
||||||
|
target: "chanora_audio",
|
||||||
|
restored_mode = prior,
|
||||||
|
"android: AudioManager mode restored during startup rollback"
|
||||||
|
),
|
||||||
|
Err(e) => warn!(
|
||||||
|
target: "chanora_audio",
|
||||||
|
error = %e,
|
||||||
|
restored_mode = prior,
|
||||||
|
"android: failed to restore AudioManager mode during startup rollback"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
crate::mode_stack::ModeRelease::StillHeld => {
|
||||||
|
info!(
|
||||||
|
target: "chanora_audio",
|
||||||
|
"android: audio mode still held during startup rollback"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
crate::mode_stack::ModeRelease::AlreadyReleased => {}
|
||||||
|
}
|
||||||
|
|
||||||
|
if crate::android_voice_unit::chanora_android_stop_voice_service() {
|
||||||
|
info!(
|
||||||
|
target: "chanora_audio",
|
||||||
|
"android: voice foreground service stopped during startup rollback"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Stop the engine. Idempotent.
|
/// Stop the engine. Idempotent.
|
||||||
pub fn stop(&mut self) {
|
pub fn stop(&mut self) {
|
||||||
if let Some(tx) = self.shutdown_tx.take() {
|
if let Some(tx) = self.shutdown_tx.take() {
|
||||||
@@ -1720,6 +1770,33 @@ impl Drop for AudioEngine {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(any(test, target_os = "android"))]
|
||||||
|
fn release_android_audio_mode_for_startup_rollback(
|
||||||
|
audio_mode_stack: &mut crate::mode_stack::ModeStack,
|
||||||
|
) -> crate::mode_stack::ModeRelease {
|
||||||
|
audio_mode_stack.release()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn android_startup_rollback_releases_acquired_mode_snapshot() {
|
||||||
|
let mut stack = crate::mode_stack::ModeStack::new();
|
||||||
|
let _ = stack.acquire(7);
|
||||||
|
|
||||||
|
let release = release_android_audio_mode_for_startup_rollback(&mut stack);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
release,
|
||||||
|
crate::mode_stack::ModeRelease::LastRelease { prior: 7 }
|
||||||
|
);
|
||||||
|
assert_eq!(stack.refcount(), 0);
|
||||||
|
assert_eq!(stack.snapshot(), None);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ---------- Capture pipeline ----------
|
// ---------- Capture pipeline ----------
|
||||||
|
|
||||||
#[cfg(not(any(target_os = "ios", target_os = "macos", target_os = "android")))]
|
#[cfg(not(any(target_os = "ios", target_os = "macos", target_os = "android")))]
|
||||||
@@ -1762,9 +1839,12 @@ fn try_open_capture(
|
|||||||
in_sample_rate,
|
in_sample_rate,
|
||||||
in_channels,
|
in_channels,
|
||||||
mic_gain,
|
mic_gain,
|
||||||
voice_out_tx,
|
crate::opus_voice::start_out_packet_worker(
|
||||||
|
voice_out_tx,
|
||||||
|
frames_sent.clone(),
|
||||||
|
"cpal-capture",
|
||||||
|
)?,
|
||||||
transmit_active,
|
transmit_active,
|
||||||
frames_sent,
|
|
||||||
audio_processing_stats,
|
audio_processing_stats,
|
||||||
)));
|
)));
|
||||||
|
|
||||||
@@ -1799,11 +1879,10 @@ struct CaptureState {
|
|||||||
/// Linux ALSA defaults).
|
/// Linux ALSA defaults).
|
||||||
resample_last: f32,
|
resample_last: f32,
|
||||||
opus_out: [u8; crate::opus_voice::MAX_OPUS_FRAME],
|
opus_out: [u8; crate::opus_voice::MAX_OPUS_FRAME],
|
||||||
voice_out_tx: mpsc::Sender<OutPacket>,
|
voice_out_tx: crate::opus_voice::EncodedVoiceFrameSender,
|
||||||
/// The PTT transmission gate. Read once per outbound frame; the
|
/// The PTT transmission gate. Read once per outbound frame; the
|
||||||
/// CaptureState never mutates this flag.
|
/// CaptureState never mutates this flag.
|
||||||
transmit_active: Arc<AtomicBool>,
|
transmit_active: Arc<AtomicBool>,
|
||||||
frames_sent: Arc<AtomicU32>,
|
|
||||||
/// Pre-allocated mono downmix buffer. Resized in-place each
|
/// Pre-allocated mono downmix buffer. Resized in-place each
|
||||||
/// callback; `clear()` retains capacity. SDD-094 realtime-thread
|
/// callback; `clear()` retains capacity. SDD-094 realtime-thread
|
||||||
/// invariant: this avoids the heap allocation that the prior fix
|
/// invariant: this avoids the heap allocation that the prior fix
|
||||||
@@ -1836,8 +1915,7 @@ struct CaptureState {
|
|||||||
/// Time-based gating is robust to cpal buffer-size and sample-rate
|
/// Time-based gating is robust to cpal buffer-size and sample-rate
|
||||||
/// changes that a fixed callback-count would not be.
|
/// changes that a fixed callback-count would not be.
|
||||||
#[cfg(not(any(target_os = "ios", target_os = "macos", target_os = "android")))]
|
#[cfg(not(any(target_os = "ios", target_os = "macos", target_os = "android")))]
|
||||||
const LEVEL_METER_INTERVAL: std::time::Duration =
|
const LEVEL_METER_INTERVAL: std::time::Duration = std::time::Duration::from_millis(33);
|
||||||
std::time::Duration::from_millis(33);
|
|
||||||
|
|
||||||
#[cfg(not(any(target_os = "ios", target_os = "macos", target_os = "android")))]
|
#[cfg(not(any(target_os = "ios", target_os = "macos", target_os = "android")))]
|
||||||
impl CaptureState {
|
impl CaptureState {
|
||||||
@@ -1846,9 +1924,8 @@ impl CaptureState {
|
|||||||
in_sample_rate: u32,
|
in_sample_rate: u32,
|
||||||
in_channels: usize,
|
in_channels: usize,
|
||||||
mic_gain: f32,
|
mic_gain: f32,
|
||||||
voice_out_tx: mpsc::Sender<OutPacket>,
|
voice_out_tx: crate::opus_voice::EncodedVoiceFrameSender,
|
||||||
transmit_active: Arc<AtomicBool>,
|
transmit_active: Arc<AtomicBool>,
|
||||||
frames_sent: Arc<AtomicU32>,
|
|
||||||
audio_processing_stats: Arc<crate::SharedAudioProcessingStats>,
|
audio_processing_stats: Arc<crate::SharedAudioProcessingStats>,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
Self {
|
Self {
|
||||||
@@ -1862,7 +1939,6 @@ impl CaptureState {
|
|||||||
opus_out: [0u8; crate::opus_voice::MAX_OPUS_FRAME],
|
opus_out: [0u8; crate::opus_voice::MAX_OPUS_FRAME],
|
||||||
voice_out_tx,
|
voice_out_tx,
|
||||||
transmit_active,
|
transmit_active,
|
||||||
frames_sent,
|
|
||||||
mono_scratch: Vec::with_capacity(4096),
|
mono_scratch: Vec::with_capacity(4096),
|
||||||
frame_scratch: Vec::with_capacity(FRAME_SAMPLES),
|
frame_scratch: Vec::with_capacity(FRAME_SAMPLES),
|
||||||
audio_processing_stats,
|
audio_processing_stats,
|
||||||
@@ -1959,7 +2035,6 @@ impl CaptureState {
|
|||||||
Ok(len) => {
|
Ok(len) => {
|
||||||
crate::opus_voice::send_voip_frame(
|
crate::opus_voice::send_voip_frame(
|
||||||
&self.voice_out_tx,
|
&self.voice_out_tx,
|
||||||
&self.frames_sent,
|
|
||||||
&self.opus_out,
|
&self.opus_out,
|
||||||
len,
|
len,
|
||||||
|| {
|
|| {
|
||||||
@@ -2373,6 +2448,13 @@ impl std::fmt::Display for AudioModeError {
|
|||||||
#[cfg(target_os = "android")]
|
#[cfg(target_os = "android")]
|
||||||
impl std::error::Error for AudioModeError {}
|
impl std::error::Error for AudioModeError {}
|
||||||
|
|
||||||
|
#[cfg(target_os = "android")]
|
||||||
|
impl From<jni::errors::Error> for AudioModeError {
|
||||||
|
fn from(value: jni::errors::Error) -> Self {
|
||||||
|
Self::JniAttachFailed(value.to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// JNI helper shared by `android_get_audio_mode` and
|
/// JNI helper shared by `android_get_audio_mode` and
|
||||||
/// `android_set_audio_mode`: attach to the current thread and return
|
/// `android_set_audio_mode`: attach to the current thread and return
|
||||||
/// the `AudioManager` jobject. Centralised so SDD-108's two platform
|
/// the `AudioManager` jobject. Centralised so SDD-108's two platform
|
||||||
@@ -2380,7 +2462,10 @@ impl std::error::Error for AudioModeError {}
|
|||||||
#[cfg(target_os = "android")]
|
#[cfg(target_os = "android")]
|
||||||
fn android_audio_manager_call<F, R>(op: F) -> Result<R, AudioModeError>
|
fn android_audio_manager_call<F, R>(op: F) -> Result<R, AudioModeError>
|
||||||
where
|
where
|
||||||
F: FnOnce(&mut jni::JNIEnv, &jni::objects::JObject) -> Result<R, AudioModeError>
|
F: for<'local> FnOnce(
|
||||||
|
&mut jni::Env<'local>,
|
||||||
|
&jni::objects::JObject<'local>,
|
||||||
|
) -> Result<R, AudioModeError>
|
||||||
+ std::panic::UnwindSafe,
|
+ std::panic::UnwindSafe,
|
||||||
{
|
{
|
||||||
use jni::objects::{JObject, JString, JValue};
|
use jni::objects::{JObject, JString, JValue};
|
||||||
@@ -2397,42 +2482,40 @@ where
|
|||||||
// at a live JavaVM* set by our bridge_init JNI hook. The
|
// at a live JavaVM* set by our bridge_init JNI hook. The
|
||||||
// unsafe block contains only the cast required by
|
// unsafe block contains only the cast required by
|
||||||
// `JavaVM::from_raw`.
|
// `JavaVM::from_raw`.
|
||||||
let jvm = unsafe { jni::JavaVM::from_raw(vm_ptr as *mut _) }
|
let jvm = unsafe { jni::JavaVM::from_raw(vm_ptr as *mut _) };
|
||||||
.map_err(|e| AudioModeError::JniAttachFailed(format!("jvm from_raw: {e}")))?;
|
jvm.attach_current_thread(|env| -> Result<R, AudioModeError> {
|
||||||
let mut env = jvm
|
let context_obj = unsafe { JObject::from_raw(env, ctx.context() as jni::sys::jobject) };
|
||||||
.attach_current_thread()
|
|
||||||
.map_err(|e| AudioModeError::JniAttachFailed(format!("attach: {e}")))?;
|
|
||||||
|
|
||||||
let context_obj = unsafe { JObject::from_raw(ctx.context() as jni::sys::jobject) };
|
let service_name: JString =
|
||||||
|
env.new_string("audio")
|
||||||
let service_name: JString =
|
.map_err(|e| AudioModeError::MethodCallFailed {
|
||||||
env.new_string("audio")
|
method: "new_string",
|
||||||
|
detail: e.to_string(),
|
||||||
|
})?;
|
||||||
|
let service_name_obj = JObject::from(service_name);
|
||||||
|
let audio_manager = env
|
||||||
|
.call_method(
|
||||||
|
&context_obj,
|
||||||
|
jni::jni_str!("getSystemService"),
|
||||||
|
jni::jni_sig!("(Ljava/lang/String;)Ljava/lang/Object;"),
|
||||||
|
&[JValue::Object(&service_name_obj)],
|
||||||
|
)
|
||||||
.map_err(|e| AudioModeError::MethodCallFailed {
|
.map_err(|e| AudioModeError::MethodCallFailed {
|
||||||
method: "new_string",
|
method: "getSystemService",
|
||||||
detail: e.to_string(),
|
detail: e.to_string(),
|
||||||
|
})?
|
||||||
|
.l()
|
||||||
|
.map_err(|e| AudioModeError::MethodCallFailed {
|
||||||
|
method: "getSystemService",
|
||||||
|
detail: format!("obj cast: {e}"),
|
||||||
})?;
|
})?;
|
||||||
let audio_manager = env
|
if audio_manager.is_null() {
|
||||||
.call_method(
|
return Err(AudioModeError::Other(
|
||||||
&context_obj,
|
"AudioManager service is null".to_string(),
|
||||||
"getSystemService",
|
));
|
||||||
"(Ljava/lang/String;)Ljava/lang/Object;",
|
}
|
||||||
&[JValue::Object(&service_name.into())],
|
op(env, &audio_manager)
|
||||||
)
|
})
|
||||||
.map_err(|e| AudioModeError::MethodCallFailed {
|
|
||||||
method: "getSystemService",
|
|
||||||
detail: e.to_string(),
|
|
||||||
})?
|
|
||||||
.l()
|
|
||||||
.map_err(|e| AudioModeError::MethodCallFailed {
|
|
||||||
method: "getSystemService",
|
|
||||||
detail: format!("obj cast: {e}"),
|
|
||||||
})?;
|
|
||||||
if audio_manager.is_null() {
|
|
||||||
return Err(AudioModeError::Other(
|
|
||||||
"AudioManager service is null".to_string(),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
op(&mut env, &audio_manager)
|
|
||||||
});
|
});
|
||||||
match result {
|
match result {
|
||||||
Ok(inner) => inner,
|
Ok(inner) => inner,
|
||||||
@@ -2451,16 +2534,21 @@ where
|
|||||||
#[cfg(target_os = "android")]
|
#[cfg(target_os = "android")]
|
||||||
pub fn android_get_audio_mode() -> Result<i32, AudioModeError> {
|
pub fn android_get_audio_mode() -> Result<i32, AudioModeError> {
|
||||||
android_audio_manager_call(|env, audio_manager| {
|
android_audio_manager_call(|env, audio_manager| {
|
||||||
env.call_method(audio_manager, "getMode", "()I", &[])
|
env.call_method(
|
||||||
.map_err(|e| AudioModeError::MethodCallFailed {
|
audio_manager,
|
||||||
method: "getMode",
|
jni::jni_str!("getMode"),
|
||||||
detail: e.to_string(),
|
jni::jni_sig!("()I"),
|
||||||
})?
|
&[],
|
||||||
.i()
|
)
|
||||||
.map_err(|e| AudioModeError::MethodCallFailed {
|
.map_err(|e| AudioModeError::MethodCallFailed {
|
||||||
method: "getMode",
|
method: "getMode",
|
||||||
detail: format!("int cast: {e}"),
|
detail: e.to_string(),
|
||||||
})
|
})?
|
||||||
|
.i()
|
||||||
|
.map_err(|e| AudioModeError::MethodCallFailed {
|
||||||
|
method: "getMode",
|
||||||
|
detail: format!("int cast: {e}"),
|
||||||
|
})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2474,11 +2562,16 @@ pub fn android_get_audio_mode() -> Result<i32, AudioModeError> {
|
|||||||
pub fn android_set_audio_mode(mode: i32) -> Result<(), AudioModeError> {
|
pub fn android_set_audio_mode(mode: i32) -> Result<(), AudioModeError> {
|
||||||
use jni::objects::JValue;
|
use jni::objects::JValue;
|
||||||
android_audio_manager_call(move |env, audio_manager| {
|
android_audio_manager_call(move |env, audio_manager| {
|
||||||
env.call_method(audio_manager, "setMode", "(I)V", &[JValue::Int(mode)])
|
env.call_method(
|
||||||
.map_err(|e| AudioModeError::MethodCallFailed {
|
audio_manager,
|
||||||
method: "setMode",
|
jni::jni_str!("setMode"),
|
||||||
detail: e.to_string(),
|
jni::jni_sig!("(I)V"),
|
||||||
})?;
|
&[JValue::Int(mode)],
|
||||||
|
)
|
||||||
|
.map_err(|e| AudioModeError::MethodCallFailed {
|
||||||
|
method: "setMode",
|
||||||
|
detail: e.to_string(),
|
||||||
|
})?;
|
||||||
Ok(())
|
Ok(())
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,13 +42,11 @@ mod inner {
|
|||||||
use coreaudio::audio_unit::render_callback::{self, data};
|
use coreaudio::audio_unit::render_callback::{self, data};
|
||||||
use coreaudio::audio_unit::IOType;
|
use coreaudio::audio_unit::IOType;
|
||||||
use coreaudio::audio_unit::{AudioUnit, Element, SampleFormat, Scope, StreamFormat};
|
use coreaudio::audio_unit::{AudioUnit, Element, SampleFormat, Scope, StreamFormat};
|
||||||
use tokio::sync::mpsc;
|
|
||||||
use tracing::{info, warn};
|
use tracing::{info, warn};
|
||||||
|
|
||||||
use crate::mobile_voice_backend::VoiceAudioParams;
|
use crate::mobile_voice_backend::VoiceAudioParams;
|
||||||
use crate::processor::AudioProcessor;
|
use crate::processor::AudioProcessor;
|
||||||
use crate::AudioError;
|
use crate::AudioError;
|
||||||
use chanora_protocol::OutPacket;
|
|
||||||
|
|
||||||
const SAMPLE_RATE_HZ: f64 = 48_000.0;
|
const SAMPLE_RATE_HZ: f64 = 48_000.0;
|
||||||
|
|
||||||
@@ -63,43 +61,10 @@ mod inner {
|
|||||||
/// If the capture callback runs before the render callback has written
|
/// If the capture callback runs before the render callback has written
|
||||||
/// a frame it reads zeros (silence reference), which is safe — Sonora
|
/// a frame it reads zeros (silence reference), which is safe — Sonora
|
||||||
/// AEC3 simply skips cancellation for that frame.
|
/// AEC3 simply skips cancellation for that frame.
|
||||||
struct RenderReferenceBuffer {
|
type RenderReferenceBuffer = crate::render_reference::RenderReferenceBuffer<480, 4>;
|
||||||
buf: Box<[[f32; 480]; 4]>,
|
type RenderReferenceFrameAccumulator =
|
||||||
write_idx: std::sync::atomic::AtomicUsize,
|
crate::render_reference::RenderReferenceFrameAccumulator<480>;
|
||||||
}
|
const RAW_RENDER_SCRATCH_FRAMES: usize = 1024;
|
||||||
|
|
||||||
impl RenderReferenceBuffer {
|
|
||||||
fn new() -> Arc<Self> {
|
|
||||||
Arc::new(Self {
|
|
||||||
buf: Box::new([[0.0; 480]; 4]),
|
|
||||||
write_idx: std::sync::atomic::AtomicUsize::new(0),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Write one 10 ms render-reference frame. Realtime-safe.
|
|
||||||
fn write(&self, frame: &[f32; 480]) {
|
|
||||||
let idx = self.write_idx.load(Ordering::Relaxed);
|
|
||||||
// SAFETY: only one writer (render callback); torn reads
|
|
||||||
// are bounded to one frame of AEC degradation.
|
|
||||||
unsafe {
|
|
||||||
let slot = &self.buf[idx] as *const [f32; 480] as *mut [f32; 480];
|
|
||||||
(*slot).copy_from_slice(frame);
|
|
||||||
}
|
|
||||||
self.write_idx.store((idx + 1) % 4, Ordering::Relaxed);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Read the most recently completed render-reference frame.
|
|
||||||
fn read_latest(&self) -> [f32; 480] {
|
|
||||||
let wi = self.write_idx.load(Ordering::Relaxed);
|
|
||||||
let ri = (wi + 3) % 4;
|
|
||||||
self.buf[ri]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// SAFETY: accessed from two audio callback threads; data races are
|
|
||||||
// bounded to one frame of AEC quality degradation.
|
|
||||||
unsafe impl Send for RenderReferenceBuffer {}
|
|
||||||
unsafe impl Sync for RenderReferenceBuffer {}
|
|
||||||
|
|
||||||
// ------------------------------------------------------------------ //
|
// ------------------------------------------------------------------ //
|
||||||
// Capture pipeline state //
|
// Capture pipeline state //
|
||||||
@@ -109,10 +74,9 @@ mod inner {
|
|||||||
encoder: OpusEncoder,
|
encoder: OpusEncoder,
|
||||||
pcm_accum: Vec<i16>,
|
pcm_accum: Vec<i16>,
|
||||||
opus_out: [u8; crate::opus_voice::MAX_OPUS_FRAME],
|
opus_out: [u8; crate::opus_voice::MAX_OPUS_FRAME],
|
||||||
voice_out_tx: mpsc::Sender<OutPacket>,
|
voice_out_tx: crate::opus_voice::EncodedVoiceFrameSender,
|
||||||
transmit_active: Arc<AtomicBool>,
|
transmit_active: Arc<AtomicBool>,
|
||||||
output_muted: Arc<AtomicBool>,
|
output_muted: Arc<AtomicBool>,
|
||||||
frames_sent: Arc<AtomicU32>,
|
|
||||||
mic_gain: f32,
|
mic_gain: f32,
|
||||||
voice_activity_selector: Option<Arc<crate::TransmitModeSelector>>,
|
voice_activity_selector: Option<Arc<crate::TransmitModeSelector>>,
|
||||||
vad_detector: crate::vad::WebRtcFallbackVad,
|
vad_detector: crate::vad::WebRtcFallbackVad,
|
||||||
@@ -128,7 +92,6 @@ mod inner {
|
|||||||
pending_10ms: [i16; crate::frame::FRAME_10MS_SAMPLES],
|
pending_10ms: [i16; crate::frame::FRAME_10MS_SAMPLES],
|
||||||
pending_10ms_len: usize,
|
pending_10ms_len: usize,
|
||||||
fallback_warned_backend: Option<crate::VadBackend>,
|
fallback_warned_backend: Option<crate::VadBackend>,
|
||||||
wav_recorder: Option<Arc<crate::debug_wav::WavDebugRecorder>>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl RawCaptureState {
|
impl RawCaptureState {
|
||||||
@@ -146,10 +109,13 @@ mod inner {
|
|||||||
encoder,
|
encoder,
|
||||||
pcm_accum: Vec::with_capacity(crate::frame::FRAME_20MS_SAMPLES * 2),
|
pcm_accum: Vec::with_capacity(crate::frame::FRAME_20MS_SAMPLES * 2),
|
||||||
opus_out: [0u8; crate::opus_voice::MAX_OPUS_FRAME],
|
opus_out: [0u8; crate::opus_voice::MAX_OPUS_FRAME],
|
||||||
voice_out_tx: params.voice_out_tx.clone(),
|
voice_out_tx: crate::opus_voice::start_out_packet_worker(
|
||||||
|
params.voice_out_tx.clone(),
|
||||||
|
params.frames_sent.clone(),
|
||||||
|
"ios-raw",
|
||||||
|
)?,
|
||||||
transmit_active: params.transmit_active.clone(),
|
transmit_active: params.transmit_active.clone(),
|
||||||
output_muted: params.output_muted.clone(),
|
output_muted: params.output_muted.clone(),
|
||||||
frames_sent: params.frames_sent.clone(),
|
|
||||||
mic_gain: params.mic_gain,
|
mic_gain: params.mic_gain,
|
||||||
voice_activity_selector: params.voice_activity_selector.clone(),
|
voice_activity_selector: params.voice_activity_selector.clone(),
|
||||||
vad_detector: crate::vad::WebRtcFallbackVad::default(),
|
vad_detector: crate::vad::WebRtcFallbackVad::default(),
|
||||||
@@ -166,7 +132,6 @@ mod inner {
|
|||||||
pending_10ms: [0_i16; crate::frame::FRAME_10MS_SAMPLES],
|
pending_10ms: [0_i16; crate::frame::FRAME_10MS_SAMPLES],
|
||||||
pending_10ms_len: 0,
|
pending_10ms_len: 0,
|
||||||
fallback_warned_backend: None,
|
fallback_warned_backend: None,
|
||||||
wav_recorder: None,
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -204,6 +169,7 @@ mod inner {
|
|||||||
if self.pending_10ms_len == crate::frame::FRAME_10MS_SAMPLES {
|
if self.pending_10ms_len == crate::frame::FRAME_10MS_SAMPLES {
|
||||||
let frame = self.pending_10ms;
|
let frame = self.pending_10ms;
|
||||||
self.process_10ms_capture_frame(&frame);
|
self.process_10ms_capture_frame(&frame);
|
||||||
|
self.encode_complete_20ms_frames();
|
||||||
self.pending_10ms_len = 0;
|
self.pending_10ms_len = 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -213,7 +179,10 @@ mod inner {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Encode complete 20 ms Opus frames.
|
self.encode_complete_20ms_frames();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn encode_complete_20ms_frames(&mut self) {
|
||||||
while self.pcm_accum.len() >= crate::frame::FRAME_20MS_SAMPLES {
|
while self.pcm_accum.len() >= crate::frame::FRAME_20MS_SAMPLES {
|
||||||
let mut frame = [0i16; crate::frame::FRAME_20MS_SAMPLES];
|
let mut frame = [0i16; crate::frame::FRAME_20MS_SAMPLES];
|
||||||
frame.copy_from_slice(&self.pcm_accum[..crate::frame::FRAME_20MS_SAMPLES]);
|
frame.copy_from_slice(&self.pcm_accum[..crate::frame::FRAME_20MS_SAMPLES]);
|
||||||
@@ -223,7 +192,6 @@ mod inner {
|
|||||||
Ok(len) => {
|
Ok(len) => {
|
||||||
crate::opus_voice::send_voip_frame(
|
crate::opus_voice::send_voip_frame(
|
||||||
&self.voice_out_tx,
|
&self.voice_out_tx,
|
||||||
&self.frames_sent,
|
|
||||||
&self.opus_out,
|
&self.opus_out,
|
||||||
len,
|
len,
|
||||||
|| {
|
|| {
|
||||||
@@ -253,20 +221,18 @@ mod inner {
|
|||||||
}
|
}
|
||||||
let input_dbfs = crate::frame::dbfs(&frame);
|
let input_dbfs = crate::frame::dbfs(&frame);
|
||||||
|
|
||||||
// WAV tap: raw mic (before processing).
|
// Debug WAV mic taps are intentionally unavailable on iOS raw
|
||||||
if let Some(ref rec) = self.wav_recorder {
|
// realtime callbacks until WavDebugRecorder supports a
|
||||||
rec.push_raw_mic(&frame);
|
// preallocated handoff path; the current recorder push path
|
||||||
}
|
// allocates per frame.
|
||||||
|
|
||||||
// Feed render reference to WebRTC APM before capture so AEC can adapt.
|
// Feed render reference to WebRTC APM before capture so AEC can adapt.
|
||||||
let render_ref = self.render_reference.read_latest();
|
let render_ref = self.render_reference.read_latest();
|
||||||
self.webrtc_apm_processor.process_render(&render_ref);
|
self.webrtc_apm_processor.process_render(&render_ref);
|
||||||
self.webrtc_apm_processor.process_capture(&mut frame);
|
self.webrtc_apm_processor.process_capture(&mut frame);
|
||||||
|
|
||||||
// WAV tap: processed mic (after WebRTC APM).
|
// Processed-mic debug WAV capture is disabled for the same
|
||||||
if let Some(ref rec) = self.wav_recorder {
|
// realtime allocation reason as the raw-mic tap above.
|
||||||
rec.push_processed_mic(&frame);
|
|
||||||
}
|
|
||||||
|
|
||||||
let voice_activity_mode = self
|
let voice_activity_mode = self
|
||||||
.voice_activity_selector
|
.voice_activity_selector
|
||||||
@@ -300,14 +266,9 @@ mod inner {
|
|||||||
self.current_vad_backend = vad_backend;
|
self.current_vad_backend = vad_backend;
|
||||||
self.fallback_warned_backend = None;
|
self.fallback_warned_backend = None;
|
||||||
if vad_backend == crate::VadBackend::SileroOnnx {
|
if vad_backend == crate::VadBackend::SileroOnnx {
|
||||||
self.silero_coreml_worker =
|
self.silero_coreml_worker = None;
|
||||||
crate::vad::apple_coreml::AppleCoreMlVadWorker::try_new();
|
self.mark_vad_fallback_active(crate::VadBackend::SileroOnnx);
|
||||||
if self.silero_coreml_worker.is_none() {
|
self.audio_processing_stats.set_vad_fallback_active(true);
|
||||||
self.mark_vad_fallback_active(crate::VadBackend::SileroOnnx);
|
|
||||||
self.audio_processing_stats.set_vad_fallback_active(true);
|
|
||||||
} else {
|
|
||||||
self.audio_processing_stats.set_vad_fallback_active(false);
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
self.silero_coreml_worker = None;
|
self.silero_coreml_worker = None;
|
||||||
self.audio_processing_stats.set_vad_fallback_active(false);
|
self.audio_processing_stats.set_vad_fallback_active(false);
|
||||||
@@ -325,20 +286,38 @@ mod inner {
|
|||||||
speech: true,
|
speech: true,
|
||||||
}
|
}
|
||||||
} else if vad_backend == crate::VadBackend::SileroOnnx {
|
} else if vad_backend == crate::VadBackend::SileroOnnx {
|
||||||
if let Some(worker) = self.silero_coreml_worker.as_ref() {
|
match crate::vad::callback_vad_worker_policy(
|
||||||
let enqueued = worker.try_send(capture_seq, &frame);
|
voice_activity_mode,
|
||||||
if !worker.is_stale(capture_seq) {
|
vad_backend,
|
||||||
let p = worker.latest_probability();
|
self.silero_coreml_worker.is_some(),
|
||||||
crate::vad::VadOutput {
|
) {
|
||||||
probability: p,
|
crate::vad::VadWorkerPolicy::UseWorker => {
|
||||||
speech: p >= 0.5,
|
let worker = self
|
||||||
|
.silero_coreml_worker
|
||||||
|
.as_ref()
|
||||||
|
.expect("policy checked worker");
|
||||||
|
let enqueued = worker.try_send(capture_seq, &frame);
|
||||||
|
if !worker.is_stale(capture_seq) {
|
||||||
|
let p = worker.latest_probability();
|
||||||
|
crate::vad::VadOutput {
|
||||||
|
probability: p,
|
||||||
|
speech: p >= 0.5,
|
||||||
|
}
|
||||||
|
} else if enqueued {
|
||||||
|
crate::vad::VadOutput {
|
||||||
|
probability: 0.0,
|
||||||
|
speech: false,
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
used_fallback_vad = true;
|
||||||
|
self.mark_vad_fallback_active(vad_backend);
|
||||||
|
crate::vad::VoiceActivityDetector::process_10ms(
|
||||||
|
&mut self.vad_detector,
|
||||||
|
&frame,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
} else if enqueued {
|
}
|
||||||
crate::vad::VadOutput {
|
crate::vad::VadWorkerPolicy::UseFallback => {
|
||||||
probability: 0.0,
|
|
||||||
speech: false,
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
used_fallback_vad = true;
|
used_fallback_vad = true;
|
||||||
self.mark_vad_fallback_active(vad_backend);
|
self.mark_vad_fallback_active(vad_backend);
|
||||||
crate::vad::VoiceActivityDetector::process_10ms(
|
crate::vad::VoiceActivityDetector::process_10ms(
|
||||||
@@ -346,13 +325,10 @@ mod inner {
|
|||||||
&frame,
|
&frame,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
} else {
|
crate::vad::VadWorkerPolicy::NotModelBacked => crate::vad::VadOutput {
|
||||||
used_fallback_vad = true;
|
probability: 1.0,
|
||||||
self.mark_vad_fallback_active(vad_backend);
|
speech: true,
|
||||||
crate::vad::VoiceActivityDetector::process_10ms(
|
},
|
||||||
&mut self.vad_detector,
|
|
||||||
&frame,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
crate::vad::VoiceActivityDetector::process_10ms(&mut self.vad_detector, &frame)
|
crate::vad::VoiceActivityDetector::process_10ms(&mut self.vad_detector, &frame)
|
||||||
@@ -378,15 +354,12 @@ mod inner {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let gain = self.mic_gain;
|
if crate::capture_accumulator::append_processed_i16_bounded(
|
||||||
if (gain - 1.0).abs() < f32::EPSILON {
|
&mut self.pcm_accum,
|
||||||
self.pcm_accum
|
&frame,
|
||||||
.extend(frame.iter().copied().map(crate::frame::f32_to_i16));
|
self.mic_gain,
|
||||||
} else {
|
) {
|
||||||
self.pcm_accum.extend(frame.iter().copied().map(|s| {
|
self.audio_processing_stats.increment_callback_xrun();
|
||||||
let scaled = crate::frame::f32_to_i16(s) as f32 * gain;
|
|
||||||
scaled.clamp(i16::MIN as f32, i16::MAX as f32) as i16
|
|
||||||
}));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -446,7 +419,9 @@ mod inner {
|
|||||||
})
|
})
|
||||||
.map_err(|e| AudioError::Backend(format!("remoteio input cb: {e}")))?;
|
.map_err(|e| AudioError::Backend(format!("remoteio input cb: {e}")))?;
|
||||||
|
|
||||||
let mut scratch: Vec<f32> = Vec::with_capacity(2048);
|
let mut scratch = [0.0_f32; RAW_RENDER_SCRATCH_FRAMES * 2];
|
||||||
|
let mut mono = [0.0_f32; RAW_RENDER_SCRATCH_FRAMES];
|
||||||
|
let mut render_ref_accum = RenderReferenceFrameAccumulator::new();
|
||||||
let handler = params.handler.clone();
|
let handler = params.handler.clone();
|
||||||
let output_gain = params.output_gain.clone();
|
let output_gain = params.output_gain.clone();
|
||||||
let output_muted = params.output_muted.clone();
|
let output_muted = params.output_muted.clone();
|
||||||
@@ -455,9 +430,10 @@ mod inner {
|
|||||||
unit.set_render_callback(move |args: render_callback::Args<data::Interleaved<i16>>| {
|
unit.set_render_callback(move |args: render_callback::Args<data::Interleaved<i16>>| {
|
||||||
let out = args.data.buffer;
|
let out = args.data.buffer;
|
||||||
let n = out.len();
|
let n = out.len();
|
||||||
let stereo_n = n * 2;
|
let process_n = n.min(RAW_RENDER_SCRATCH_FRAMES);
|
||||||
if scratch.len() < stereo_n {
|
let stereo_n = process_n * 2;
|
||||||
scratch.resize(stereo_n, 0.0);
|
if n > RAW_RENDER_SCRATCH_FRAMES {
|
||||||
|
stats_render.increment_callback_xrun();
|
||||||
}
|
}
|
||||||
scratch[..stereo_n].fill(0.0);
|
scratch[..stereo_n].fill(0.0);
|
||||||
|
|
||||||
@@ -475,22 +451,26 @@ mod inner {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// INV_012: copy render reference BEFORE playout.
|
// INV_012: copy render reference BEFORE playout.
|
||||||
let mono_n = n.min(480);
|
|
||||||
let mut ref_frame = [0.0_f32; 480];
|
|
||||||
crate::voice_render::downmix_stereo_f32_to_mono_f32(
|
crate::voice_render::downmix_stereo_f32_to_mono_f32(
|
||||||
&scratch[..stereo_n],
|
&scratch[..stereo_n],
|
||||||
&mut ref_frame[..mono_n],
|
&mut mono[..process_n],
|
||||||
);
|
);
|
||||||
render_ref_buf.write(&ref_frame);
|
render_ref_accum.push_mono_samples(&mono[..process_n], |frame| {
|
||||||
|
render_ref_buf.write(frame);
|
||||||
|
});
|
||||||
|
|
||||||
let gain = f32::from_bits(output_gain.load(Ordering::Relaxed));
|
let gain = f32::from_bits(output_gain.load(Ordering::Relaxed));
|
||||||
let muted = output_muted.load(Ordering::Relaxed);
|
let muted = output_muted.load(Ordering::Relaxed);
|
||||||
let mix_stats = crate::voice_render::downmix_stereo_f32_to_mono_i16(
|
let mix_stats = crate::voice_render::downmix_stereo_f32_to_interleaved_i16(
|
||||||
&scratch[..stereo_n],
|
&scratch[..stereo_n],
|
||||||
out,
|
&mut out[..process_n],
|
||||||
|
1,
|
||||||
gain,
|
gain,
|
||||||
muted,
|
muted,
|
||||||
);
|
);
|
||||||
|
if process_n < n {
|
||||||
|
out[process_n..].fill(0);
|
||||||
|
}
|
||||||
if mix_stats.clipped_samples > 0 {
|
if mix_stats.clipped_samples > 0 {
|
||||||
stats_render.add_clipped_samples(mix_stats.clipped_samples);
|
stats_render.add_clipped_samples(mix_stats.clipped_samples);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -66,7 +66,7 @@
|
|||||||
//! * AVAudioSession category / mode configuration — Swift owns the
|
//! * AVAudioSession category / mode configuration — Swift owns the
|
||||||
//! session (it must be set up before Flutter loads).
|
//! session (it must be set up before Flutter loads).
|
||||||
|
|
||||||
use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
use audiopus::coder::Encoder as OpusEncoder;
|
use audiopus::coder::Encoder as OpusEncoder;
|
||||||
@@ -75,12 +75,10 @@ use coreaudio::audio_unit::render_callback::{self, data};
|
|||||||
use coreaudio::audio_unit::IOType;
|
use coreaudio::audio_unit::IOType;
|
||||||
use coreaudio::audio_unit::{AudioUnit, Element, SampleFormat, Scope, StreamFormat};
|
use coreaudio::audio_unit::{AudioUnit, Element, SampleFormat, Scope, StreamFormat};
|
||||||
use crossbeam::queue::ArrayQueue;
|
use crossbeam::queue::ArrayQueue;
|
||||||
use tokio::sync::mpsc;
|
|
||||||
use tracing::{debug, error, info, warn};
|
use tracing::{debug, error, info, warn};
|
||||||
|
|
||||||
use crate::mobile_voice_backend::VoiceAudioParams;
|
use crate::mobile_voice_backend::VoiceAudioParams;
|
||||||
use crate::AudioError;
|
use crate::AudioError;
|
||||||
use chanora_protocol::OutPacket;
|
|
||||||
|
|
||||||
/// Sample rate every layer above us assumes. Matches the Opus
|
/// Sample rate every layer above us assumes. Matches the Opus
|
||||||
/// encoder rate, the `tsclientlib::AudioHandler` mix rate, and the
|
/// encoder rate, the `tsclientlib::AudioHandler` mix rate, and the
|
||||||
@@ -105,6 +103,15 @@ const INPUT_BUS: Element = Element::Input;
|
|||||||
/// when the VAD gate opens (VAD_004 / pre_roll_ms=160).
|
/// when the VAD gate opens (VAD_004 / pre_roll_ms=160).
|
||||||
const PRE_ROLL_FRAMES: usize = 16;
|
const PRE_ROLL_FRAMES: usize = 16;
|
||||||
|
|
||||||
|
/// Enough room for the 160 ms VAD pre-roll plus a few jitter frames, without
|
||||||
|
/// growing inside the input callback.
|
||||||
|
const CAPTURE_ACCUM_CAPACITY_SAMPLES: usize = crate::frame::FRAME_10MS_SAMPLES * 20;
|
||||||
|
|
||||||
|
/// Fixed iOS render scratch capacity. Larger callback requests are truncated
|
||||||
|
/// to this capacity and the remaining output is silence.
|
||||||
|
#[cfg_attr(not(target_os = "ios"), allow(dead_code))]
|
||||||
|
const IOS_RENDER_SCRATCH_FRAMES: usize = 4096;
|
||||||
|
|
||||||
/// Capture pipeline state owned by the VPIO input callback. The
|
/// Capture pipeline state owned by the VPIO input callback. The
|
||||||
/// AudioUnit hands us 48 kHz signed-int16 mono PCM directly (no
|
/// AudioUnit hands us 48 kHz signed-int16 mono PCM directly (no
|
||||||
/// downmix or resample needed — VPIO's hardware-side mix-down
|
/// downmix or resample needed — VPIO's hardware-side mix-down
|
||||||
@@ -132,10 +139,9 @@ struct IosCaptureState {
|
|||||||
/// jitter without reallocating.
|
/// jitter without reallocating.
|
||||||
pcm_accum: Vec<i16>,
|
pcm_accum: Vec<i16>,
|
||||||
opus_out: [u8; crate::opus_voice::MAX_OPUS_FRAME],
|
opus_out: [u8; crate::opus_voice::MAX_OPUS_FRAME],
|
||||||
voice_out_tx: mpsc::Sender<OutPacket>,
|
voice_out_tx: crate::opus_voice::EncodedVoiceFrameSender,
|
||||||
transmit_active: Arc<AtomicBool>,
|
transmit_active: Arc<AtomicBool>,
|
||||||
output_muted: Arc<AtomicBool>,
|
output_muted: Arc<AtomicBool>,
|
||||||
frames_sent: Arc<AtomicU32>,
|
|
||||||
mic_gain: f32,
|
mic_gain: f32,
|
||||||
voice_activity_selector: Option<Arc<crate::TransmitModeSelector>>,
|
voice_activity_selector: Option<Arc<crate::TransmitModeSelector>>,
|
||||||
vad_detector: crate::vad::WebRtcFallbackVad,
|
vad_detector: crate::vad::WebRtcFallbackVad,
|
||||||
@@ -154,7 +160,6 @@ struct IosCaptureState {
|
|||||||
pre_roll_count: usize,
|
pre_roll_count: usize,
|
||||||
pre_roll_flushed: bool,
|
pre_roll_flushed: bool,
|
||||||
capture_frame_seq: u64,
|
capture_frame_seq: u64,
|
||||||
wav_recorder: Arc<Mutex<Option<Arc<crate::debug_wav::WavDebugRecorder>>>>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl IosCaptureState {
|
impl IosCaptureState {
|
||||||
@@ -162,20 +167,20 @@ impl IosCaptureState {
|
|||||||
/// Encoder configuration is the same as cpal-side
|
/// Encoder configuration is the same as cpal-side
|
||||||
/// `try_open_capture` (engine.rs) so audio quality is platform-
|
/// `try_open_capture` (engine.rs) so audio quality is platform-
|
||||||
/// neutral.
|
/// neutral.
|
||||||
fn new(
|
fn new(params: &VoiceAudioParams) -> Result<Self, AudioError> {
|
||||||
params: &VoiceAudioParams,
|
|
||||||
wav_recorder: Arc<Mutex<Option<Arc<crate::debug_wav::WavDebugRecorder>>>>,
|
|
||||||
) -> Result<Self, AudioError> {
|
|
||||||
let encoder = crate::opus_voice::new_voip_encoder("ios VPIO")?;
|
let encoder = crate::opus_voice::new_voip_encoder("ios VPIO")?;
|
||||||
|
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
encoder,
|
encoder,
|
||||||
pcm_accum: Vec::with_capacity(crate::frame::FRAME_20MS_SAMPLES * 2),
|
pcm_accum: Vec::with_capacity(CAPTURE_ACCUM_CAPACITY_SAMPLES),
|
||||||
opus_out: [0u8; crate::opus_voice::MAX_OPUS_FRAME],
|
opus_out: [0u8; crate::opus_voice::MAX_OPUS_FRAME],
|
||||||
voice_out_tx: params.voice_out_tx.clone(),
|
voice_out_tx: crate::opus_voice::start_out_packet_worker(
|
||||||
|
params.voice_out_tx.clone(),
|
||||||
|
params.frames_sent.clone(),
|
||||||
|
"ios-vpio",
|
||||||
|
)?,
|
||||||
transmit_active: params.transmit_active.clone(),
|
transmit_active: params.transmit_active.clone(),
|
||||||
output_muted: params.output_muted.clone(),
|
output_muted: params.output_muted.clone(),
|
||||||
frames_sent: params.frames_sent.clone(),
|
|
||||||
mic_gain: params.mic_gain,
|
mic_gain: params.mic_gain,
|
||||||
voice_activity_selector: params.voice_activity_selector.clone(),
|
voice_activity_selector: params.voice_activity_selector.clone(),
|
||||||
vad_detector: crate::vad::WebRtcFallbackVad::default(),
|
vad_detector: crate::vad::WebRtcFallbackVad::default(),
|
||||||
@@ -193,7 +198,6 @@ impl IosCaptureState {
|
|||||||
pre_roll_count: 0,
|
pre_roll_count: 0,
|
||||||
pre_roll_flushed: false,
|
pre_roll_flushed: false,
|
||||||
capture_frame_seq: 0,
|
capture_frame_seq: 0,
|
||||||
wav_recorder,
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -267,7 +271,6 @@ impl IosCaptureState {
|
|||||||
Ok(len) => {
|
Ok(len) => {
|
||||||
crate::opus_voice::send_voip_frame(
|
crate::opus_voice::send_voip_frame(
|
||||||
&self.voice_out_tx,
|
&self.voice_out_tx,
|
||||||
&self.frames_sent,
|
|
||||||
&self.opus_out,
|
&self.opus_out,
|
||||||
len,
|
len,
|
||||||
|| {
|
|| {
|
||||||
@@ -298,25 +301,9 @@ impl IosCaptureState {
|
|||||||
}
|
}
|
||||||
let input_dbfs = crate::frame::dbfs(&frame);
|
let input_dbfs = crate::frame::dbfs(&frame);
|
||||||
|
|
||||||
// WAV tap: raw mic (before processing, DIAG_002).
|
|
||||||
if let Ok(guard) = self.wav_recorder.try_lock() {
|
|
||||||
if let Some(rec) = guard.as_ref() {
|
|
||||||
rec.push_raw_mic(&frame);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Read config once per frame (try_lock: non-blocking, falls back to
|
// Read config once per frame (try_lock: non-blocking, falls back to
|
||||||
// last-known values if the lock is contended — safe to miss one frame).
|
// last-known values if the lock is contended — safe to miss one frame).
|
||||||
let (
|
let (run_ns, run_agc, run_hpf, vad_backend, vad_hangover, debug_wav_dump_enabled) = self
|
||||||
run_ns,
|
|
||||||
run_agc,
|
|
||||||
run_hpf,
|
|
||||||
vad_backend,
|
|
||||||
vad_hangover,
|
|
||||||
debug_wav_dump_enabled,
|
|
||||||
route,
|
|
||||||
processing_backend,
|
|
||||||
) = self
|
|
||||||
.audio_processing_config
|
.audio_processing_config
|
||||||
.try_lock()
|
.try_lock()
|
||||||
.map(|cfg| {
|
.map(|cfg| {
|
||||||
@@ -332,8 +319,6 @@ impl IosCaptureState {
|
|||||||
cfg.vad_backend,
|
cfg.vad_backend,
|
||||||
cfg.vad_hangover_ms,
|
cfg.vad_hangover_ms,
|
||||||
cfg.debug_wav_dump_enabled,
|
cfg.debug_wav_dump_enabled,
|
||||||
cfg.route,
|
|
||||||
cfg.processing_backend,
|
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
.unwrap_or((
|
.unwrap_or((
|
||||||
@@ -343,10 +328,13 @@ impl IosCaptureState {
|
|||||||
crate::VadBackend::WebrtcVad,
|
crate::VadBackend::WebrtcVad,
|
||||||
crate::voice_activity::VAD_HANGOVER_MS,
|
crate::voice_activity::VAD_HANGOVER_MS,
|
||||||
false,
|
false,
|
||||||
crate::AudioRoute::Unknown,
|
|
||||||
crate::AudioBackend::PlatformVoiceProcessing,
|
|
||||||
));
|
));
|
||||||
|
|
||||||
|
// VPIO realtime callbacks cannot use WavDebugRecorder today: its push
|
||||||
|
// path allocates per frame. Debug WAV capture is intentionally disabled
|
||||||
|
// here until the recorder can hand off preallocated frames.
|
||||||
|
let _ = debug_wav_dump_enabled;
|
||||||
|
|
||||||
let voice_activity_mode = self
|
let voice_activity_mode = self
|
||||||
.voice_activity_selector
|
.voice_activity_selector
|
||||||
.as_ref()
|
.as_ref()
|
||||||
@@ -359,32 +347,13 @@ impl IosCaptureState {
|
|||||||
self.audio_processing_stats.set_vad_fallback_active(false);
|
self.audio_processing_stats.set_vad_fallback_active(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Switch VAD backend only while VoiceActivity mode is active.
|
|
||||||
if let Ok(mut recorder_guard) = self.wav_recorder.try_lock() {
|
|
||||||
if debug_wav_dump_enabled {
|
|
||||||
if recorder_guard.is_none() {
|
|
||||||
*recorder_guard = Some(crate::debug_wav::WavDebugRecorder::start(
|
|
||||||
route,
|
|
||||||
processing_backend,
|
|
||||||
));
|
|
||||||
}
|
|
||||||
} else if let Some(recorder) = recorder_guard.take() {
|
|
||||||
recorder.stop();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if voice_activity_mode && vad_backend != self.current_vad_backend {
|
if voice_activity_mode && vad_backend != self.current_vad_backend {
|
||||||
self.current_vad_backend = vad_backend;
|
self.current_vad_backend = vad_backend;
|
||||||
self.fallback_warned_backend = None;
|
self.fallback_warned_backend = None;
|
||||||
if vad_backend == crate::VadBackend::SileroOnnx {
|
if vad_backend == crate::VadBackend::SileroOnnx {
|
||||||
self.silero_coreml_worker =
|
self.silero_coreml_worker = None;
|
||||||
crate::vad::apple_coreml::AppleCoreMlVadWorker::try_new();
|
self.mark_vad_fallback_active(crate::VadBackend::SileroOnnx);
|
||||||
if self.silero_coreml_worker.is_none() {
|
self.audio_processing_stats.set_vad_fallback_active(true);
|
||||||
self.mark_vad_fallback_active(crate::VadBackend::SileroOnnx);
|
|
||||||
self.audio_processing_stats.set_vad_fallback_active(true);
|
|
||||||
} else {
|
|
||||||
self.audio_processing_stats.set_vad_fallback_active(false);
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
self.silero_coreml_worker = None;
|
self.silero_coreml_worker = None;
|
||||||
self.audio_processing_stats.set_vad_fallback_active(false);
|
self.audio_processing_stats.set_vad_fallback_active(false);
|
||||||
@@ -437,20 +406,38 @@ impl IosCaptureState {
|
|||||||
speech: true,
|
speech: true,
|
||||||
}
|
}
|
||||||
} else if vad_backend == crate::VadBackend::SileroOnnx {
|
} else if vad_backend == crate::VadBackend::SileroOnnx {
|
||||||
if let Some(worker) = self.silero_coreml_worker.as_ref() {
|
match crate::vad::callback_vad_worker_policy(
|
||||||
let enqueued = worker.try_send(capture_seq, &frame);
|
voice_activity_mode,
|
||||||
if !worker.is_stale(capture_seq) {
|
vad_backend,
|
||||||
let p = worker.latest_probability();
|
self.silero_coreml_worker.is_some(),
|
||||||
crate::vad::VadOutput {
|
) {
|
||||||
probability: p,
|
crate::vad::VadWorkerPolicy::UseWorker => {
|
||||||
speech: p >= 0.5,
|
let worker = self
|
||||||
|
.silero_coreml_worker
|
||||||
|
.as_ref()
|
||||||
|
.expect("policy checked worker");
|
||||||
|
let enqueued = worker.try_send(capture_seq, &frame);
|
||||||
|
if !worker.is_stale(capture_seq) {
|
||||||
|
let p = worker.latest_probability();
|
||||||
|
crate::vad::VadOutput {
|
||||||
|
probability: p,
|
||||||
|
speech: p >= 0.5,
|
||||||
|
}
|
||||||
|
} else if enqueued {
|
||||||
|
crate::vad::VadOutput {
|
||||||
|
probability: 0.0,
|
||||||
|
speech: false,
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
used_fallback_vad = true;
|
||||||
|
self.mark_vad_fallback_active(crate::VadBackend::SileroOnnx);
|
||||||
|
crate::vad::VoiceActivityDetector::process_10ms(
|
||||||
|
&mut self.vad_detector,
|
||||||
|
&frame,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
} else if enqueued {
|
}
|
||||||
crate::vad::VadOutput {
|
crate::vad::VadWorkerPolicy::UseFallback => {
|
||||||
probability: 0.0,
|
|
||||||
speech: false,
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
used_fallback_vad = true;
|
used_fallback_vad = true;
|
||||||
self.mark_vad_fallback_active(crate::VadBackend::SileroOnnx);
|
self.mark_vad_fallback_active(crate::VadBackend::SileroOnnx);
|
||||||
crate::vad::VoiceActivityDetector::process_10ms(
|
crate::vad::VoiceActivityDetector::process_10ms(
|
||||||
@@ -458,10 +445,10 @@ impl IosCaptureState {
|
|||||||
&frame,
|
&frame,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
} else {
|
crate::vad::VadWorkerPolicy::NotModelBacked => crate::vad::VadOutput {
|
||||||
used_fallback_vad = true;
|
probability: 1.0,
|
||||||
self.mark_vad_fallback_active(crate::VadBackend::SileroOnnx);
|
speech: true,
|
||||||
crate::vad::VoiceActivityDetector::process_10ms(&mut self.vad_detector, &frame)
|
},
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
crate::vad::VoiceActivityDetector::process_10ms(&mut self.vad_detector, &frame)
|
crate::vad::VoiceActivityDetector::process_10ms(&mut self.vad_detector, &frame)
|
||||||
@@ -484,13 +471,6 @@ impl IosCaptureState {
|
|||||||
transmit_active,
|
transmit_active,
|
||||||
);
|
);
|
||||||
|
|
||||||
// WAV tap: processed mic (after Rust DSP, DIAG_002).
|
|
||||||
if let Ok(guard) = self.wav_recorder.try_lock() {
|
|
||||||
if let Some(rec) = guard.as_ref() {
|
|
||||||
rec.push_processed_mic(&frame);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Convert to i16 for accumulation.
|
// Convert to i16 for accumulation.
|
||||||
let mut pcm_frame = [0_i16; crate::frame::FRAME_10MS_SAMPLES];
|
let mut pcm_frame = [0_i16; crate::frame::FRAME_10MS_SAMPLES];
|
||||||
if (self.mic_gain - 1.0).abs() < f32::EPSILON {
|
if (self.mic_gain - 1.0).abs() < f32::EPSILON {
|
||||||
@@ -528,7 +508,13 @@ impl IosCaptureState {
|
|||||||
let pre_roll_to_emit = self.pre_roll_count.saturating_sub(1);
|
let pre_roll_to_emit = self.pre_roll_count.saturating_sub(1);
|
||||||
for i in 0..pre_roll_to_emit {
|
for i in 0..pre_roll_to_emit {
|
||||||
let idx = (oldest + i) % PRE_ROLL_FRAMES;
|
let idx = (oldest + i) % PRE_ROLL_FRAMES;
|
||||||
self.pcm_accum.extend_from_slice(&self.pre_roll_buf[idx]);
|
if crate::capture_accumulator::append_i16_bounded(
|
||||||
|
&mut self.pcm_accum,
|
||||||
|
&self.pre_roll_buf[idx],
|
||||||
|
) {
|
||||||
|
self.audio_processing_stats.increment_callback_xrun();
|
||||||
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else if !transmit_active {
|
} else if !transmit_active {
|
||||||
// Gate closed — reset the flush flag so pre-roll fires again
|
// Gate closed — reset the flush flag so pre-roll fires again
|
||||||
@@ -540,7 +526,9 @@ impl IosCaptureState {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
self.pcm_accum.extend_from_slice(&pcm_frame);
|
if crate::capture_accumulator::append_i16_bounded(&mut self.pcm_accum, &pcm_frame) {
|
||||||
|
self.audio_processing_stats.increment_callback_xrun();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -694,9 +682,7 @@ impl IosVoiceUnit {
|
|||||||
Element::Output,
|
Element::Output,
|
||||||
Some(&ducking_config),
|
Some(&ducking_config),
|
||||||
) {
|
) {
|
||||||
tracing::debug!(
|
tracing::debug!("vpio set OtherAudioDuckingConfiguration failed (older OS?): {e}");
|
||||||
"vpio set OtherAudioDuckingConfiguration failed (older OS?): {e}"
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Note: we keep VPIO's voice processing chain ENABLED
|
// Note: we keep VPIO's voice processing chain ENABLED
|
||||||
@@ -748,18 +734,7 @@ impl IosVoiceUnit {
|
|||||||
// scratch are owned by the closure — no Mutex needed
|
// scratch are owned by the closure — no Mutex needed
|
||||||
// because the input callback is the sole writer/reader on
|
// because the input callback is the sole writer/reader on
|
||||||
// the audio thread.
|
// the audio thread.
|
||||||
let wav_recorder = Arc::new(Mutex::new({
|
let mut capture_state = IosCaptureState::new(¶ms)?;
|
||||||
let cfg = params.audio_processing_config.lock().unwrap().clone();
|
|
||||||
if cfg.debug_wav_dump_enabled {
|
|
||||||
Some(crate::debug_wav::WavDebugRecorder::start(
|
|
||||||
cfg.route,
|
|
||||||
cfg.processing_backend,
|
|
||||||
))
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
}
|
|
||||||
}));
|
|
||||||
let mut capture_state = IosCaptureState::new(¶ms, wav_recorder.clone())?;
|
|
||||||
|
|
||||||
unit.set_input_callback(move |args: render_callback::Args<data::Interleaved<i16>>| {
|
unit.set_input_callback(move |args: render_callback::Args<data::Interleaved<i16>>| {
|
||||||
// VPIO with our pinned stream format delivers
|
// VPIO with our pinned stream format delivers
|
||||||
@@ -879,11 +854,8 @@ impl IosVoiceUnit {
|
|||||||
|
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
let mut pull_scratch: Vec<f32> = vec![0.0; PULL_SAMPLES];
|
let mut pull_scratch: Vec<f32> = vec![0.0; PULL_SAMPLES];
|
||||||
let mut interval =
|
let mut interval = tokio::time::interval(std::time::Duration::from_millis(20));
|
||||||
tokio::time::interval(std::time::Duration::from_millis(20));
|
interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
|
||||||
interval.set_missed_tick_behavior(
|
|
||||||
tokio::time::MissedTickBehavior::Delay,
|
|
||||||
);
|
|
||||||
loop {
|
loop {
|
||||||
interval.tick().await;
|
interval.tick().await;
|
||||||
if producer_shutdown_for_task.load(Ordering::Relaxed) {
|
if producer_shutdown_for_task.load(Ordering::Relaxed) {
|
||||||
@@ -909,16 +881,16 @@ impl IosVoiceUnit {
|
|||||||
|
|
||||||
unit.set_render_callback(move |args: render_callback::Args<data::Interleaved<i16>>| {
|
unit.set_render_callback(move |args: render_callback::Args<data::Interleaved<i16>>| {
|
||||||
let render_callback::Args {
|
let render_callback::Args {
|
||||||
data,
|
data, num_frames, ..
|
||||||
num_frames,
|
|
||||||
..
|
|
||||||
} = args;
|
} = args;
|
||||||
let out: &mut [i16] = data.buffer;
|
let out: &mut [i16] = data.buffer;
|
||||||
let out_channels = data.channels;
|
let out_channels = data.channels;
|
||||||
let needed = num_frames * out_channels;
|
let needed = num_frames * out_channels;
|
||||||
|
|
||||||
if pcm_ring_consumer.len() < PREBUFFER_SAMPLES {
|
if pcm_ring_consumer.len() < PREBUFFER_SAMPLES {
|
||||||
for sample in &mut out[..needed] { *sample = 0; }
|
for sample in &mut out[..needed] {
|
||||||
|
*sample = 0;
|
||||||
|
}
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -939,10 +911,8 @@ impl IosVoiceUnit {
|
|||||||
let mono = (l_lim + r_lim) * 0.5;
|
let mono = (l_lim + r_lim) * 0.5;
|
||||||
out[base] = (mono.clamp(-1.0, 1.0) * i16::MAX as f32) as i16;
|
out[base] = (mono.clamp(-1.0, 1.0) * i16::MAX as f32) as i16;
|
||||||
} else {
|
} else {
|
||||||
out[base] =
|
out[base] = (l_lim.clamp(-1.0, 1.0) * i16::MAX as f32) as i16;
|
||||||
(l_lim.clamp(-1.0, 1.0) * i16::MAX as f32) as i16;
|
out[base + 1] = (r_lim.clamp(-1.0, 1.0) * i16::MAX as f32) as i16;
|
||||||
out[base + 1] =
|
|
||||||
(r_lim.clamp(-1.0, 1.0) * i16::MAX as f32) as i16;
|
|
||||||
}
|
}
|
||||||
written_frames += 1;
|
written_frames += 1;
|
||||||
}
|
}
|
||||||
@@ -951,17 +921,18 @@ impl IosVoiceUnit {
|
|||||||
let remaining = num_frames - written_frames;
|
let remaining = num_frames - written_frames;
|
||||||
for f in 0..remaining {
|
for f in 0..remaining {
|
||||||
let base = (written_frames + f) * out_channels;
|
let base = (written_frames + f) * out_channels;
|
||||||
for c in 0..out_channels { out[base + c] = 0; }
|
for c in 0..out_channels {
|
||||||
|
out[base + c] = 0;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let gain = f32::from_bits(
|
let gain = f32::from_bits(output_gain_for_render.load(Ordering::Relaxed));
|
||||||
output_gain_for_render.load(Ordering::Relaxed),
|
let muted = output_muted_for_render.load(Ordering::Relaxed);
|
||||||
);
|
|
||||||
let muted =
|
|
||||||
output_muted_for_render.load(Ordering::Relaxed);
|
|
||||||
if muted {
|
if muted {
|
||||||
for sample in &mut out[..needed] { *sample = 0; }
|
for sample in &mut out[..needed] {
|
||||||
|
*sample = 0;
|
||||||
|
}
|
||||||
} else if gain != 1.0 {
|
} else if gain != 1.0 {
|
||||||
for sample in &mut out[..needed] {
|
for sample in &mut out[..needed] {
|
||||||
*sample = (((*sample as f32) * gain)
|
*sample = (((*sample as f32) * gain)
|
||||||
@@ -972,9 +943,7 @@ impl IosVoiceUnit {
|
|||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
})
|
})
|
||||||
.map_err(|e| AudioError::Backend(format!(
|
.map_err(|e| AudioError::Backend(format!("audio unit set render callback: {e}")))?;
|
||||||
"audio unit set render callback: {e}"
|
|
||||||
)))?;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// iOS path: direct fill_buffer in callback. iOS VPIO
|
// iOS path: direct fill_buffer in callback. iOS VPIO
|
||||||
@@ -984,127 +953,112 @@ impl IosVoiceUnit {
|
|||||||
// producer-task path above.
|
// producer-task path above.
|
||||||
#[cfg(target_os = "ios")]
|
#[cfg(target_os = "ios")]
|
||||||
{
|
{
|
||||||
let mut scratch_stereo: Vec<f32> = vec![0.0; 4096 * 2];
|
let mut scratch_stereo: Vec<f32> = vec![0.0; IOS_RENDER_SCRATCH_FRAMES * 2];
|
||||||
let handler_for_render = params.handler.clone();
|
let handler_for_render = params.handler.clone();
|
||||||
let output_gain_for_render = params.output_gain.clone();
|
let output_gain_for_render = params.output_gain.clone();
|
||||||
let output_muted_for_render = params.output_muted.clone();
|
let output_muted_for_render = params.output_muted.clone();
|
||||||
let audio_processing_stats_for_render = params.audio_processing_stats.clone();
|
let audio_processing_stats_for_render = params.audio_processing_stats.clone();
|
||||||
// Level meter decimation: the render callback fires ~93
|
// Level meter decimation: the render callback fires ~93
|
||||||
// times/sec, but the bridge consumer reads at ~30 Hz.
|
// times/sec, but the bridge consumer reads at ~30 Hz.
|
||||||
let mut render_level_decimation: u32 = 0;
|
let mut render_level_decimation: u32 = 0;
|
||||||
unit.set_render_callback(move |args: render_callback::Args<data::Interleaved<i16>>| {
|
// Debug WAV render-reference capture is intentionally unavailable
|
||||||
let render_callback::Args {
|
// on iOS VPIO callbacks until WavDebugRecorder supports a
|
||||||
data,
|
// preallocated handoff; its current push path allocates per frame.
|
||||||
num_frames,
|
// Diagnostic counters sampled every 100 callbacks.
|
||||||
..
|
let mut cb_count: u64 = 0;
|
||||||
} = args;
|
let mut last_num_frames: usize = 0;
|
||||||
let out: &mut [i16] = data.buffer;
|
let mut num_frames_changes: u64 = 0;
|
||||||
let out_channels = data.channels;
|
let mut callbacks_with_audio: u64 = 0;
|
||||||
// AudioHandler produces 48 kHz stereo f32 (= num_frames * 2 floats).
|
let mut callbacks_with_silence: u64 = 0;
|
||||||
let needed = num_frames * 2;
|
unit.set_render_callback(move |args: render_callback::Args<data::Interleaved<i16>>| {
|
||||||
if scratch_stereo.len() < needed {
|
let render_callback::Args {
|
||||||
scratch_stereo.resize(needed, 0.0);
|
data, num_frames, ..
|
||||||
}
|
} = args;
|
||||||
// Zero the live slice. AudioHandler::fill_buffer is
|
let out: &mut [i16] = data.buffer;
|
||||||
// additive (does NOT clear); residual values from
|
let out_channels = data.channels;
|
||||||
// earlier callbacks (when scratch was bigger) would
|
let process_frames = num_frames.min(IOS_RENDER_SCRATCH_FRAMES);
|
||||||
// leak through otherwise.
|
if process_frames < num_frames {
|
||||||
scratch_stereo[..needed].fill(0.0);
|
|
||||||
match handler_for_render.try_lock() {
|
|
||||||
Ok(mut h) => {
|
|
||||||
let _ = h.fill_buffer(&mut scratch_stereo[..needed]);
|
|
||||||
}
|
|
||||||
Err(std::sync::TryLockError::WouldBlock) => {
|
|
||||||
audio_processing_stats_for_render.increment_callback_xrun();
|
audio_processing_stats_for_render.increment_callback_xrun();
|
||||||
// scratch_stereo is already zeroed above.
|
|
||||||
}
|
}
|
||||||
Err(std::sync::TryLockError::Poisoned(e)) => {
|
// AudioHandler produces 48 kHz stereo f32 (= frames * 2 floats).
|
||||||
// Never panic on the realtime IO thread.
|
let needed = process_frames * 2;
|
||||||
warn!(target: "chanora_audio", "AudioHandler mutex poisoned: {e}");
|
// Zero the live slice. AudioHandler::fill_buffer is
|
||||||
}
|
// additive (does NOT clear); residual values from
|
||||||
}
|
// earlier callbacks (when scratch was bigger) would
|
||||||
// Peak limiter — multi-client mixes can sum past 0 dBFS;
|
// leak through otherwise.
|
||||||
// without this the downmix helper would hard-clip to i16::MAX.
|
scratch_stereo[..needed].fill(0.0);
|
||||||
crate::voice_render::limit_peak_inplace(&mut scratch_stereo[..needed], 0.99);
|
match handler_for_render.try_lock() {
|
||||||
let gain = f32::from_bits(output_gain_for_render.load(Ordering::Relaxed));
|
Ok(mut h) => {
|
||||||
let muted = output_muted_for_render.load(Ordering::Relaxed);
|
let _ = h.fill_buffer(&mut scratch_stereo[..needed]);
|
||||||
let mix_stats = crate::voice_render::downmix_stereo_f32_to_interleaved_i16(
|
|
||||||
&scratch_stereo[..needed],
|
|
||||||
out,
|
|
||||||
out_channels,
|
|
||||||
gain,
|
|
||||||
muted,
|
|
||||||
);
|
|
||||||
if mix_stats.clipped_samples > 0 {
|
|
||||||
audio_processing_stats_for_render.add_clipped_samples(mix_stats.clipped_samples);
|
|
||||||
}
|
|
||||||
render_level_decimation = render_level_decimation.wrapping_add(1);
|
|
||||||
if render_level_decimation % 3 == 0 {
|
|
||||||
audio_processing_stats_for_render.update_render(
|
|
||||||
crate::frame::dbfs(&scratch_stereo[..needed]),
|
|
||||||
num_frames as u32,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Ok(guard) = wav_recorder_for_render.try_lock() {
|
|
||||||
if let Some(rec) = guard.as_ref() {
|
|
||||||
if !render_recorder_active {
|
|
||||||
render_ref_len = 0;
|
|
||||||
render_ref_accum.fill(0.0);
|
|
||||||
render_recorder_active = true;
|
|
||||||
}
|
}
|
||||||
let mut idx = 0;
|
Err(std::sync::TryLockError::WouldBlock) => {
|
||||||
while idx + 1 < needed {
|
audio_processing_stats_for_render.increment_callback_xrun();
|
||||||
let mono = (scratch_stereo[idx] + scratch_stereo[idx + 1]) * 0.5;
|
// scratch_stereo is already zeroed above.
|
||||||
render_ref_accum[render_ref_len] = mono;
|
}
|
||||||
render_ref_len += 1;
|
Err(std::sync::TryLockError::Poisoned(e)) => {
|
||||||
idx += 2;
|
// Never panic on the realtime IO thread.
|
||||||
if render_ref_len == crate::frame::FRAME_10MS_SAMPLES {
|
warn!(target: "chanora_audio", "AudioHandler mutex poisoned: {e}");
|
||||||
rec.push_render_reference(&render_ref_accum);
|
|
||||||
render_ref_len = 0;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
render_recorder_active = false;
|
|
||||||
}
|
}
|
||||||
} else {
|
// Peak limiter — multi-client mixes can sum past 0 dBFS;
|
||||||
render_recorder_active = false;
|
// without this the downmix helper would hard-clip to i16::MAX.
|
||||||
}
|
crate::voice_render::limit_peak_inplace(&mut scratch_stereo[..needed], 0.99);
|
||||||
// Track audio-vs-silence for the diagnostic.
|
let gain = f32::from_bits(output_gain_for_render.load(Ordering::Relaxed));
|
||||||
if mix_stats.peak_i16 > 0 {
|
let muted = output_muted_for_render.load(Ordering::Relaxed);
|
||||||
callbacks_with_audio = callbacks_with_audio.wrapping_add(1);
|
let mix_stats = crate::voice_render::downmix_stereo_f32_to_interleaved_i16(
|
||||||
} else {
|
&scratch_stereo[..needed],
|
||||||
callbacks_with_silence = callbacks_with_silence.wrapping_add(1);
|
out,
|
||||||
// Muted output writes intentional silence (peak_i16 == 0 by
|
out_channels,
|
||||||
// design), not a starved render path. Gate on !muted to avoid
|
|
||||||
// counting deliberate silence as an output underrun.
|
|
||||||
if !muted {
|
|
||||||
audio_processing_stats_for_render.increment_output_underrun();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Diagnostic sampling.
|
|
||||||
if last_num_frames != 0 && last_num_frames != num_frames {
|
|
||||||
num_frames_changes = num_frames_changes.wrapping_add(1);
|
|
||||||
}
|
|
||||||
last_num_frames = num_frames;
|
|
||||||
cb_count = cb_count.wrapping_add(1);
|
|
||||||
if cb_count.is_multiple_of(100) {
|
|
||||||
debug!(
|
|
||||||
target: "chanora_audio",
|
|
||||||
cb = cb_count,
|
|
||||||
num_frames,
|
|
||||||
frames_changes = num_frames_changes,
|
|
||||||
callbacks_with_audio,
|
|
||||||
callbacks_with_silence,
|
|
||||||
peak_out_i16 = mix_stats.peak_i16,
|
|
||||||
gain,
|
gain,
|
||||||
"ios audio unit render callback diagnostic sample (direct fill_buffer)"
|
muted,
|
||||||
);
|
);
|
||||||
}
|
if mix_stats.clipped_samples > 0 {
|
||||||
Ok(())
|
audio_processing_stats_for_render
|
||||||
})
|
.add_clipped_samples(mix_stats.clipped_samples);
|
||||||
.map_err(|e| AudioError::Backend(format!("audio unit set render callback: {e}")))?;
|
}
|
||||||
|
render_level_decimation = render_level_decimation.wrapping_add(1);
|
||||||
|
if render_level_decimation % 3 == 0 {
|
||||||
|
audio_processing_stats_for_render.update_render(
|
||||||
|
crate::frame::dbfs(&scratch_stereo[..needed]),
|
||||||
|
num_frames as u32,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Track audio-vs-silence for the diagnostic.
|
||||||
|
if mix_stats.peak_i16 > 0 {
|
||||||
|
callbacks_with_audio = callbacks_with_audio.wrapping_add(1);
|
||||||
|
} else {
|
||||||
|
callbacks_with_silence = callbacks_with_silence.wrapping_add(1);
|
||||||
|
// Muted output writes intentional silence (peak_i16 == 0 by
|
||||||
|
// design), not a starved render path. Gate on !muted to avoid
|
||||||
|
// counting deliberate silence as an output underrun.
|
||||||
|
if !muted {
|
||||||
|
audio_processing_stats_for_render.increment_output_underrun();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Diagnostic sampling.
|
||||||
|
if last_num_frames != 0 && last_num_frames != num_frames {
|
||||||
|
num_frames_changes = num_frames_changes.wrapping_add(1);
|
||||||
|
}
|
||||||
|
last_num_frames = num_frames;
|
||||||
|
cb_count = cb_count.wrapping_add(1);
|
||||||
|
if cb_count.is_multiple_of(100) {
|
||||||
|
debug!(
|
||||||
|
target: "chanora_audio",
|
||||||
|
cb = cb_count,
|
||||||
|
num_frames,
|
||||||
|
frames_changes = num_frames_changes,
|
||||||
|
callbacks_with_audio,
|
||||||
|
callbacks_with_silence,
|
||||||
|
peak_out_i16 = mix_stats.peak_i16,
|
||||||
|
gain,
|
||||||
|
"ios audio unit render callback diagnostic sample (direct fill_buffer)"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
.map_err(|e| AudioError::Backend(format!("audio unit set render callback: {e}")))?;
|
||||||
} // end #[cfg(target_os = "ios")] block
|
} // end #[cfg(target_os = "ios")] block
|
||||||
|
|
||||||
// Finalise the unit — allocates internal buffers per the
|
// Finalise the unit — allocates internal buffers per the
|
||||||
|
|||||||
@@ -28,10 +28,17 @@
|
|||||||
|
|
||||||
#![warn(missing_docs)]
|
#![warn(missing_docs)]
|
||||||
|
|
||||||
|
#[cfg(any(target_os = "android", test))]
|
||||||
|
#[cfg_attr(not(target_os = "android"), allow(dead_code))]
|
||||||
|
mod android_render_ring;
|
||||||
#[cfg(any(target_os = "android", test))]
|
#[cfg(any(target_os = "android", test))]
|
||||||
#[cfg_attr(not(target_os = "android"), allow(dead_code))]
|
#[cfg_attr(not(target_os = "android"), allow(dead_code))]
|
||||||
mod audio_event_queue;
|
mod audio_event_queue;
|
||||||
pub mod audio_processing;
|
pub mod audio_processing;
|
||||||
|
#[cfg_attr(not(target_os = "android"), allow(dead_code))]
|
||||||
|
mod capture_accumulator;
|
||||||
|
#[cfg_attr(not(target_os = "android"), allow(dead_code))]
|
||||||
|
mod capture_resampler;
|
||||||
pub mod debug_wav;
|
pub mod debug_wav;
|
||||||
mod engine;
|
mod engine;
|
||||||
pub mod frame;
|
pub mod frame;
|
||||||
@@ -42,6 +49,11 @@ pub mod processor;
|
|||||||
pub mod ptt;
|
pub mod ptt;
|
||||||
pub mod ptt_backends;
|
pub mod ptt_backends;
|
||||||
pub mod release_tail;
|
pub mod release_tail;
|
||||||
|
#[cfg_attr(
|
||||||
|
not(any(target_os = "android", target_os = "ios", test)),
|
||||||
|
allow(dead_code)
|
||||||
|
)]
|
||||||
|
pub(crate) mod render_reference;
|
||||||
pub mod route_policy;
|
pub mod route_policy;
|
||||||
pub mod transmit_mode;
|
pub mod transmit_mode;
|
||||||
pub mod transmit_selector;
|
pub mod transmit_selector;
|
||||||
|
|||||||
@@ -3,15 +3,18 @@ use audiopus::{
|
|||||||
Application as OpusApp, Bitrate as OpusBitrate, Channels as OpusChannels,
|
Application as OpusApp, Bitrate as OpusBitrate, Channels as OpusChannels,
|
||||||
SampleRate as OpusSampleRate,
|
SampleRate as OpusSampleRate,
|
||||||
};
|
};
|
||||||
use std::sync::atomic::{AtomicU32, Ordering};
|
use crossbeam::queue::ArrayQueue;
|
||||||
|
use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
|
||||||
|
use std::sync::Arc;
|
||||||
use tokio::sync::mpsc;
|
use tokio::sync::mpsc;
|
||||||
use tracing::{info, warn};
|
use tracing::{debug, info, warn};
|
||||||
|
|
||||||
use chanora_protocol::{AudioData, CodecType, OutAudio, OutPacket};
|
use chanora_protocol::{AudioData, CodecType, OutAudio, OutPacket};
|
||||||
|
|
||||||
use crate::AudioError;
|
use crate::AudioError;
|
||||||
|
|
||||||
pub(crate) const MAX_OPUS_FRAME: usize = 1275;
|
pub(crate) const MAX_OPUS_FRAME: usize = 1275;
|
||||||
|
const VOICE_FRAME_QUEUE_CAPACITY: usize = 64;
|
||||||
|
|
||||||
const VOIP_BITRATE_BPS: i32 = 32_000;
|
const VOIP_BITRATE_BPS: i32 = 32_000;
|
||||||
const VOIP_COMPLEXITY: u8 = 10;
|
const VOIP_COMPLEXITY: u8 = 10;
|
||||||
@@ -54,10 +57,129 @@ pub(crate) fn tune_voip_encoder(encoder: &mut OpusEncoder, context: &str) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) struct EncodedVoiceFrame {
|
||||||
|
data: [u8; MAX_OPUS_FRAME],
|
||||||
|
len: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) struct EncodedVoiceFrameSender {
|
||||||
|
queue: Arc<ArrayQueue<EncodedVoiceFrame>>,
|
||||||
|
open: Arc<AtomicBool>,
|
||||||
|
}
|
||||||
|
|
||||||
|
enum EncodedVoiceFrameSendError {
|
||||||
|
Full,
|
||||||
|
Closed,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl EncodedVoiceFrameSender {
|
||||||
|
fn new(capacity: usize) -> Self {
|
||||||
|
Self {
|
||||||
|
queue: Arc::new(ArrayQueue::new(capacity)),
|
||||||
|
open: Arc::new(AtomicBool::new(true)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn worker_queue(&self) -> Arc<ArrayQueue<EncodedVoiceFrame>> {
|
||||||
|
Arc::clone(&self.queue)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn worker_open_flag(&self) -> Arc<AtomicBool> {
|
||||||
|
Arc::clone(&self.open)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn push(&self, frame: EncodedVoiceFrame) -> Result<(), EncodedVoiceFrameSendError> {
|
||||||
|
if !self.open.load(Ordering::Relaxed) {
|
||||||
|
return Err(EncodedVoiceFrameSendError::Closed);
|
||||||
|
}
|
||||||
|
self.queue
|
||||||
|
.push(frame)
|
||||||
|
.map_err(|_| EncodedVoiceFrameSendError::Full)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn start_out_packet_worker(
|
||||||
|
voice_out_tx: mpsc::Sender<OutPacket>,
|
||||||
|
frames_sent: Arc<AtomicU32>,
|
||||||
|
context: &'static str,
|
||||||
|
) -> Result<EncodedVoiceFrameSender, AudioError> {
|
||||||
|
start_out_packet_worker_with_spawner(voice_out_tx, frames_sent, context, |name, worker| {
|
||||||
|
std::thread::Builder::new()
|
||||||
|
.name(name)
|
||||||
|
.spawn(worker)
|
||||||
|
.map(|_| ())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn start_out_packet_worker_with_spawner<S>(
|
||||||
|
voice_out_tx: mpsc::Sender<OutPacket>,
|
||||||
|
frames_sent: Arc<AtomicU32>,
|
||||||
|
context: &'static str,
|
||||||
|
spawn: S,
|
||||||
|
) -> Result<EncodedVoiceFrameSender, AudioError>
|
||||||
|
where
|
||||||
|
S: FnOnce(String, Box<dyn FnOnce() + Send + 'static>) -> std::io::Result<()>,
|
||||||
|
{
|
||||||
|
let tx = EncodedVoiceFrameSender::new(VOICE_FRAME_QUEUE_CAPACITY);
|
||||||
|
let rx = tx.worker_queue();
|
||||||
|
let worker_open = tx.worker_open_flag();
|
||||||
|
spawn(
|
||||||
|
format!("chanora-{context}-voice-packets"),
|
||||||
|
Box::new(move || {
|
||||||
|
loop {
|
||||||
|
let Some(frame) = rx.pop() else {
|
||||||
|
if Arc::strong_count(&rx) == 1 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
std::thread::sleep(std::time::Duration::from_millis(1));
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let packet = OutAudio::new(&AudioData::C2S {
|
||||||
|
id: 0,
|
||||||
|
codec: CodecType::OpusVoice,
|
||||||
|
data: frame.as_slice(),
|
||||||
|
});
|
||||||
|
match voice_out_tx.try_send(packet) {
|
||||||
|
Ok(()) => {
|
||||||
|
frames_sent.fetch_add(1, Ordering::Relaxed);
|
||||||
|
}
|
||||||
|
Err(mpsc::error::TrySendError::Full(_)) => {
|
||||||
|
warn!(target: "chanora_audio", context = %context, "voice_out queue full; dropping frame");
|
||||||
|
}
|
||||||
|
Err(mpsc::error::TrySendError::Closed(_)) => {
|
||||||
|
debug!(target: "chanora_audio", context = %context, "voice_out closed; voice packet worker stopping");
|
||||||
|
worker_open.store(false, Ordering::Relaxed);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.map_err(|e| {
|
||||||
|
tx.open.store(false, Ordering::Relaxed);
|
||||||
|
AudioError::Backend(format!("voice packet worker spawn ({context}): {e}"))
|
||||||
|
})?;
|
||||||
|
Ok(tx)
|
||||||
|
}
|
||||||
|
|
||||||
|
impl EncodedVoiceFrame {
|
||||||
|
fn try_from_opus(opus_out: &[u8], len: usize) -> Option<Self> {
|
||||||
|
if len > opus_out.len() || len > MAX_OPUS_FRAME {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let mut data = [0u8; MAX_OPUS_FRAME];
|
||||||
|
data[..len].copy_from_slice(&opus_out[..len]);
|
||||||
|
Some(Self { data, len })
|
||||||
|
}
|
||||||
|
|
||||||
|
fn as_slice(&self) -> &[u8] {
|
||||||
|
&self.data[..self.len]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Encode-scope send helper for a freshly encoded Opus voice frame.
|
/// Encode-scope send helper for a freshly encoded Opus voice frame.
|
||||||
pub(crate) fn send_voip_frame<F, G>(
|
pub(crate) fn send_voip_frame<F, G>(
|
||||||
voice_out_tx: &mpsc::Sender<OutPacket>,
|
voice_out_tx: &EncodedVoiceFrameSender,
|
||||||
frames_sent: &AtomicU32,
|
|
||||||
opus_out: &[u8],
|
opus_out: &[u8],
|
||||||
len: usize,
|
len: usize,
|
||||||
on_full: F,
|
on_full: F,
|
||||||
@@ -66,16 +188,77 @@ pub(crate) fn send_voip_frame<F, G>(
|
|||||||
F: FnOnce(),
|
F: FnOnce(),
|
||||||
G: FnOnce(),
|
G: FnOnce(),
|
||||||
{
|
{
|
||||||
let packet = OutAudio::new(&AudioData::C2S {
|
let Some(frame) = EncodedVoiceFrame::try_from_opus(opus_out, len) else {
|
||||||
id: 0,
|
on_full();
|
||||||
codec: CodecType::OpusVoice,
|
return;
|
||||||
data: &opus_out[..len],
|
};
|
||||||
});
|
match voice_out_tx.push(frame) {
|
||||||
match voice_out_tx.try_send(packet) {
|
Ok(()) => {}
|
||||||
Ok(()) => {
|
Err(EncodedVoiceFrameSendError::Full) => on_full(),
|
||||||
frames_sent.fetch_add(1, Ordering::Relaxed);
|
Err(EncodedVoiceFrameSendError::Closed) => on_closed(),
|
||||||
}
|
}
|
||||||
Err(mpsc::error::TrySendError::Full(_)) => on_full(),
|
}
|
||||||
Err(mpsc::error::TrySendError::Closed(_)) => on_closed(),
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn encoded_voice_frame_copies_into_fixed_storage() {
|
||||||
|
let source = [7u8; MAX_OPUS_FRAME];
|
||||||
|
|
||||||
|
let frame = EncodedVoiceFrame::try_from_opus(&source, MAX_OPUS_FRAME).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(frame.as_slice().len(), MAX_OPUS_FRAME);
|
||||||
|
assert!(frame.as_slice().iter().all(|byte| *byte == 7));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn encoded_voice_frame_rejects_lengths_beyond_fixed_storage() {
|
||||||
|
let source = [0u8; MAX_OPUS_FRAME];
|
||||||
|
|
||||||
|
assert!(EncodedVoiceFrame::try_from_opus(&source, MAX_OPUS_FRAME + 1).is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn encoded_voice_frame_sender_reports_full_without_blocking() {
|
||||||
|
let sender = EncodedVoiceFrameSender::new(1);
|
||||||
|
let source = [3u8; MAX_OPUS_FRAME];
|
||||||
|
let first = EncodedVoiceFrame::try_from_opus(&source, 4).unwrap();
|
||||||
|
let second = EncodedVoiceFrame::try_from_opus(&source, 4).unwrap();
|
||||||
|
|
||||||
|
assert!(sender.push(first).is_ok());
|
||||||
|
assert!(sender.push(second).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn encoded_voice_frame_sender_reports_closed_without_queueing() {
|
||||||
|
let sender = EncodedVoiceFrameSender::new(1);
|
||||||
|
sender.open.store(false, Ordering::Relaxed);
|
||||||
|
let source = [3u8; MAX_OPUS_FRAME];
|
||||||
|
let frame = EncodedVoiceFrame::try_from_opus(&source, 4).unwrap();
|
||||||
|
|
||||||
|
assert!(matches!(
|
||||||
|
sender.push(frame),
|
||||||
|
Err(EncodedVoiceFrameSendError::Closed)
|
||||||
|
));
|
||||||
|
assert_eq!(sender.queue.len(), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn encoded_voice_frame_sender_reports_spawn_failure() {
|
||||||
|
let (voice_out_tx, _voice_out_rx) = mpsc::channel(1);
|
||||||
|
let frames_sent = Arc::new(AtomicU32::new(0));
|
||||||
|
|
||||||
|
let result = start_out_packet_worker_with_spawner(
|
||||||
|
voice_out_tx,
|
||||||
|
frames_sent,
|
||||||
|
"test",
|
||||||
|
|_name, _worker| Err(std::io::Error::other("spawn failed")),
|
||||||
|
);
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
matches!(result, Err(AudioError::Backend(message)) if message.contains("spawn failed"))
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,6 +22,7 @@
|
|||||||
use core::fmt;
|
use core::fmt;
|
||||||
|
|
||||||
use crate::ptt::{AudioTransmitGate, PttBackendDescriptor};
|
use crate::ptt::{AudioTransmitGate, PttBackendDescriptor};
|
||||||
|
use thiserror::Error;
|
||||||
|
|
||||||
mod focused;
|
mod focused;
|
||||||
|
|
||||||
@@ -108,35 +109,51 @@ impl fmt::Display for PttInputClass {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Errors raised by a desktop PTT backend.
|
/// Errors raised by a desktop PTT backend.
|
||||||
#[derive(Debug)]
|
#[derive(Debug, Error)]
|
||||||
pub enum PttBackendError {
|
pub enum PttBackendError {
|
||||||
/// The OS rejected the backend initialisation (e.g. Raw Input
|
/// The OS rejected the backend initialisation (e.g. Raw Input
|
||||||
/// registration failed, event tap creation failed).
|
/// registration failed, event tap creation failed).
|
||||||
|
#[error("init failed: {0}")]
|
||||||
Init(String),
|
Init(String),
|
||||||
/// The user-granted permission required for global capture is
|
/// The user-granted permission required for global capture is
|
||||||
/// not granted (typically macOS Input Monitoring / Accessibility).
|
/// not granted (typically macOS Input Monitoring / Accessibility).
|
||||||
|
#[error("permission denied")]
|
||||||
PermissionDenied,
|
PermissionDenied,
|
||||||
/// The display server or compositor does not expose the
|
/// The display server or compositor does not expose the
|
||||||
/// expected interface (typically a non-tested Linux compositor).
|
/// expected interface (typically a non-tested Linux compositor).
|
||||||
|
#[error("unsupported environment")]
|
||||||
UnsupportedEnvironment,
|
UnsupportedEnvironment,
|
||||||
/// Caller submitted a binding whose `platform_key` cannot be
|
/// Caller submitted a binding whose `platform_key` cannot be
|
||||||
/// parsed in the active OS.
|
/// parsed in the active OS.
|
||||||
|
#[error("invalid binding: {0}")]
|
||||||
InvalidBinding(String),
|
InvalidBinding(String),
|
||||||
}
|
}
|
||||||
|
|
||||||
impl fmt::Display for PttBackendError {
|
#[cfg(test)]
|
||||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
mod tests {
|
||||||
match self {
|
use super::*;
|
||||||
Self::Init(s) => write!(f, "init failed: {s}"),
|
|
||||||
Self::PermissionDenied => f.write_str("permission denied"),
|
#[test]
|
||||||
Self::UnsupportedEnvironment => f.write_str("unsupported environment"),
|
fn ptt_backend_error_display_strings_stay_stable() {
|
||||||
Self::InvalidBinding(s) => write!(f, "invalid binding: {s}"),
|
assert_eq!(
|
||||||
}
|
PttBackendError::Init("rawinput".into()).to_string(),
|
||||||
|
"init failed: rawinput"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
PttBackendError::PermissionDenied.to_string(),
|
||||||
|
"permission denied"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
PttBackendError::UnsupportedEnvironment.to_string(),
|
||||||
|
"unsupported environment"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
PttBackendError::InvalidBinding("bad key".into()).to_string(),
|
||||||
|
"invalid binding: bad key"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl std::error::Error for PttBackendError {}
|
|
||||||
|
|
||||||
/// Cross-platform desktop PTT backend (SDD-081).
|
/// Cross-platform desktop PTT backend (SDD-081).
|
||||||
///
|
///
|
||||||
/// All implementations call exactly the audio transmit gate's
|
/// All implementations call exactly the audio transmit gate's
|
||||||
|
|||||||
@@ -0,0 +1,241 @@
|
|||||||
|
use std::array;
|
||||||
|
use std::sync::atomic::{AtomicU32, AtomicUsize, Ordering};
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
const NO_LATEST_SLOT: usize = usize::MAX;
|
||||||
|
|
||||||
|
struct Slot<const SAMPLES: usize> {
|
||||||
|
version: AtomicUsize,
|
||||||
|
samples: [AtomicU32; SAMPLES],
|
||||||
|
#[cfg(test)]
|
||||||
|
bump_after_first_sample_read: std::sync::atomic::AtomicBool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<const SAMPLES: usize> Slot<SAMPLES> {
|
||||||
|
fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
version: AtomicUsize::new(0),
|
||||||
|
samples: array::from_fn(|_| AtomicU32::new(0.0_f32.to_bits())),
|
||||||
|
#[cfg(test)]
|
||||||
|
bump_after_first_sample_read: std::sync::atomic::AtomicBool::new(false),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) struct RenderReferenceFrameAccumulator<const SAMPLES: usize> {
|
||||||
|
pending: [f32; SAMPLES],
|
||||||
|
pending_len: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<const SAMPLES: usize> RenderReferenceFrameAccumulator<SAMPLES> {
|
||||||
|
pub(crate) fn new() -> Self {
|
||||||
|
assert!(
|
||||||
|
SAMPLES > 0,
|
||||||
|
"RenderReferenceFrameAccumulator requires at least one sample"
|
||||||
|
);
|
||||||
|
Self {
|
||||||
|
pending: [0.0; SAMPLES],
|
||||||
|
pending_len: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn push_mono_samples(
|
||||||
|
&mut self,
|
||||||
|
mut samples: &[f32],
|
||||||
|
mut publish: impl FnMut(&[f32; SAMPLES]),
|
||||||
|
) {
|
||||||
|
while !samples.is_empty() {
|
||||||
|
let needed = SAMPLES - self.pending_len;
|
||||||
|
let take = needed.min(samples.len());
|
||||||
|
self.pending[self.pending_len..self.pending_len + take]
|
||||||
|
.copy_from_slice(&samples[..take]);
|
||||||
|
self.pending_len += take;
|
||||||
|
samples = &samples[take..];
|
||||||
|
|
||||||
|
if self.pending_len == SAMPLES {
|
||||||
|
publish(&self.pending);
|
||||||
|
self.pending_len = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
fn pending_len(&self) -> usize {
|
||||||
|
self.pending_len
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) struct RenderReferenceBuffer<const SAMPLES: usize, const SLOTS: usize> {
|
||||||
|
slots: Box<[Slot<SAMPLES>; SLOTS]>,
|
||||||
|
write_idx: AtomicUsize,
|
||||||
|
latest_slot: AtomicUsize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<const SAMPLES: usize, const SLOTS: usize> RenderReferenceBuffer<SAMPLES, SLOTS> {
|
||||||
|
pub(crate) fn new() -> Arc<Self> {
|
||||||
|
assert!(
|
||||||
|
SLOTS > 0,
|
||||||
|
"RenderReferenceBuffer requires at least one slot"
|
||||||
|
);
|
||||||
|
Arc::new(Self {
|
||||||
|
slots: Box::new(array::from_fn(|_| Slot::new())),
|
||||||
|
write_idx: AtomicUsize::new(0),
|
||||||
|
latest_slot: AtomicUsize::new(NO_LATEST_SLOT),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn write(&self, frame: &[f32; SAMPLES]) {
|
||||||
|
let idx = self.write_idx.load(Ordering::Relaxed) % SLOTS;
|
||||||
|
let slot = &self.slots[idx];
|
||||||
|
// The acquire half keeps payload stores after the odd in-progress marker.
|
||||||
|
let version = slot.version.fetch_add(1, Ordering::AcqRel);
|
||||||
|
debug_assert_eq!(version & 1, 0, "single writer should only enter even slots");
|
||||||
|
for (sample, value) in slot.samples.iter().zip(frame.iter().copied()) {
|
||||||
|
sample.store(value.to_bits(), Ordering::Relaxed);
|
||||||
|
}
|
||||||
|
slot.version
|
||||||
|
.store(version.wrapping_add(2) & !1, Ordering::Release);
|
||||||
|
self.latest_slot.store(idx, Ordering::Release);
|
||||||
|
self.write_idx.store((idx + 1) % SLOTS, Ordering::Relaxed);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn read_latest(&self) -> [f32; SAMPLES] {
|
||||||
|
let mut out = [0.0_f32; SAMPLES];
|
||||||
|
self.read_latest_into(&mut out);
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn read_latest_into(&self, out: &mut [f32; SAMPLES]) {
|
||||||
|
let idx = self.latest_slot.load(Ordering::Acquire);
|
||||||
|
if idx == NO_LATEST_SLOT {
|
||||||
|
out.fill(0.0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let slot = &self.slots[idx];
|
||||||
|
let before = slot.version.load(Ordering::Acquire);
|
||||||
|
if before & 1 == 1 {
|
||||||
|
out.fill(0.0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(test))]
|
||||||
|
for (dst, sample) in out.iter_mut().zip(slot.samples.iter()) {
|
||||||
|
*dst = f32::from_bits(sample.load(Ordering::Relaxed));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
for (idx, (dst, sample)) in out.iter_mut().zip(slot.samples.iter()).enumerate() {
|
||||||
|
*dst = f32::from_bits(sample.load(Ordering::Relaxed));
|
||||||
|
if idx == 0
|
||||||
|
&& slot
|
||||||
|
.bump_after_first_sample_read
|
||||||
|
.swap(false, Ordering::Relaxed)
|
||||||
|
{
|
||||||
|
slot.version.fetch_add(2, Ordering::Release);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let after = slot.version.load(Ordering::Acquire);
|
||||||
|
if before != after || after & 1 == 1 {
|
||||||
|
out.fill(0.0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
fn mark_latest_slot_in_progress_for_test(&self) {
|
||||||
|
let idx = self.latest_slot.load(Ordering::Acquire);
|
||||||
|
assert_ne!(idx, NO_LATEST_SLOT);
|
||||||
|
self.slots[idx].version.fetch_or(1, Ordering::Release);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
fn bump_latest_slot_version_after_first_sample_for_test(&self) {
|
||||||
|
let idx = self.latest_slot.load(Ordering::Acquire);
|
||||||
|
assert_ne!(idx, NO_LATEST_SLOT);
|
||||||
|
self.slots[idx]
|
||||||
|
.bump_after_first_sample_read
|
||||||
|
.store(true, Ordering::Relaxed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::{RenderReferenceBuffer, RenderReferenceFrameAccumulator};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn render_reference_reads_zero_before_first_publish() {
|
||||||
|
let buffer = RenderReferenceBuffer::<4, 2>::new();
|
||||||
|
|
||||||
|
assert_eq!(buffer.read_latest(), [0.0; 4]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn render_reference_reader_gets_latest_complete_frame() {
|
||||||
|
let buffer = RenderReferenceBuffer::<4, 3>::new();
|
||||||
|
|
||||||
|
buffer.write(&[1.0, 2.0, 3.0, 4.0]);
|
||||||
|
buffer.write(&[5.0, 6.0, 7.0, 8.0]);
|
||||||
|
|
||||||
|
assert_eq!(buffer.read_latest(), [5.0, 6.0, 7.0, 8.0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn render_reference_writes_wrap_without_returning_stale_frame() {
|
||||||
|
let buffer = RenderReferenceBuffer::<2, 2>::new();
|
||||||
|
|
||||||
|
buffer.write(&[1.0, 2.0]);
|
||||||
|
buffer.write(&[3.0, 4.0]);
|
||||||
|
buffer.write(&[5.0, 6.0]);
|
||||||
|
|
||||||
|
assert_eq!(buffer.read_latest(), [5.0, 6.0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn render_reference_accumulator_publishes_only_complete_frames() {
|
||||||
|
let mut accum = RenderReferenceFrameAccumulator::<4>::new();
|
||||||
|
let mut frames = Vec::new();
|
||||||
|
|
||||||
|
accum.push_mono_samples(&[1.0, 2.0], |frame| frames.push(*frame));
|
||||||
|
|
||||||
|
assert!(frames.is_empty());
|
||||||
|
assert_eq!(accum.pending_len(), 2);
|
||||||
|
|
||||||
|
accum.push_mono_samples(&[3.0, 4.0, 5.0, 6.0, 7.0], |frame| frames.push(*frame));
|
||||||
|
|
||||||
|
assert_eq!(frames, vec![[1.0, 2.0, 3.0, 4.0]]);
|
||||||
|
assert_eq!(accum.pending_len(), 3);
|
||||||
|
|
||||||
|
accum.push_mono_samples(&[8.0], |frame| frames.push(*frame));
|
||||||
|
|
||||||
|
assert_eq!(frames, vec![[1.0, 2.0, 3.0, 4.0], [5.0, 6.0, 7.0, 8.0]]);
|
||||||
|
assert_eq!(accum.pending_len(), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn render_reference_reader_rejects_in_progress_slot() {
|
||||||
|
let buffer = RenderReferenceBuffer::<2, 1>::new();
|
||||||
|
|
||||||
|
buffer.write(&[1.0, 2.0]);
|
||||||
|
buffer.mark_latest_slot_in_progress_for_test();
|
||||||
|
|
||||||
|
assert_eq!(buffer.read_latest(), [0.0, 0.0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn render_reference_reader_rejects_stale_slot_changed_during_read() {
|
||||||
|
let buffer = RenderReferenceBuffer::<2, 1>::new();
|
||||||
|
|
||||||
|
buffer.write(&[1.0, 2.0]);
|
||||||
|
|
||||||
|
buffer.bump_latest_slot_version_after_first_sample_for_test();
|
||||||
|
|
||||||
|
assert_eq!(buffer.read_latest(), [0.0, 0.0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[should_panic(expected = "RenderReferenceBuffer requires at least one slot")]
|
||||||
|
fn render_reference_rejects_zero_slots() {
|
||||||
|
let _ = RenderReferenceBuffer::<2, 0>::new();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,7 +15,7 @@ use std::sync::atomic::{AtomicU64, Ordering};
|
|||||||
use std::sync::{OnceLock, RwLock};
|
use std::sync::{OnceLock, RwLock};
|
||||||
|
|
||||||
use crate::frame::{f32_to_i16, i16_to_f32};
|
use crate::frame::{f32_to_i16, i16_to_f32};
|
||||||
use crate::AudioError;
|
use crate::{AudioError, VadBackend};
|
||||||
use resampler::{Downsampler48to16, INPUT_FRAME_10MS};
|
use resampler::{Downsampler48to16, INPUT_FRAME_10MS};
|
||||||
|
|
||||||
#[cfg(not(target_os = "ios"))]
|
#[cfg(not(target_os = "ios"))]
|
||||||
@@ -108,6 +108,36 @@ pub fn process_i16_10ms(detector: &mut dyn VoiceActivityDetector, samples: &[i16
|
|||||||
detector.process_10ms(&frame)
|
detector.process_10ms(&frame)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Callback-side policy for optional model-backed VAD workers.
|
||||||
|
#[derive(Debug, Clone, Copy, Eq, PartialEq)]
|
||||||
|
pub(crate) enum VadWorkerPolicy {
|
||||||
|
/// Keep using the already-available model worker.
|
||||||
|
UseWorker,
|
||||||
|
/// No worker may be constructed on the callback thread; use WebRTC fallback.
|
||||||
|
UseFallback,
|
||||||
|
/// This backend does not need a model worker.
|
||||||
|
NotModelBacked,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decide whether a realtime callback may use a model-backed VAD worker.
|
||||||
|
///
|
||||||
|
/// Model/worker construction is intentionally absent from this policy: if a
|
||||||
|
/// worker is not already present, callbacks must stay nonblocking and fall back.
|
||||||
|
pub(crate) fn callback_vad_worker_policy(
|
||||||
|
voice_activity_mode: bool,
|
||||||
|
backend: VadBackend,
|
||||||
|
worker_available: bool,
|
||||||
|
) -> VadWorkerPolicy {
|
||||||
|
if !voice_activity_mode || backend != VadBackend::SileroOnnx {
|
||||||
|
return VadWorkerPolicy::NotModelBacked;
|
||||||
|
}
|
||||||
|
if worker_available {
|
||||||
|
VadWorkerPolicy::UseWorker
|
||||||
|
} else {
|
||||||
|
VadWorkerPolicy::UseFallback
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
static SILERO_MODEL_PATH_OVERRIDE: OnceLock<RwLock<Option<String>>> = OnceLock::new();
|
static SILERO_MODEL_PATH_OVERRIDE: OnceLock<RwLock<Option<String>>> = OnceLock::new();
|
||||||
static SILERO_MODEL_EPOCH: AtomicU64 = AtomicU64::new(0);
|
static SILERO_MODEL_EPOCH: AtomicU64 = AtomicU64::new(0);
|
||||||
|
|
||||||
@@ -252,4 +282,32 @@ mod tests {
|
|||||||
assert_eq!(silero_model_bundle_path(), path.to_string_lossy());
|
assert_eq!(silero_model_bundle_path(), path.to_string_lossy());
|
||||||
let _ = std::fs::remove_file(path);
|
let _ = std::fs::remove_file(path);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn callback_policy_uses_existing_model_worker_only() {
|
||||||
|
assert_eq!(
|
||||||
|
callback_vad_worker_policy(true, VadBackend::SileroOnnx, true),
|
||||||
|
VadWorkerPolicy::UseWorker
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
callback_vad_worker_policy(true, VadBackend::SileroOnnx, false),
|
||||||
|
VadWorkerPolicy::UseFallback
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn callback_policy_keeps_disabled_and_webrtc_paths_worker_free() {
|
||||||
|
assert_eq!(
|
||||||
|
callback_vad_worker_policy(false, VadBackend::SileroOnnx, false),
|
||||||
|
VadWorkerPolicy::NotModelBacked
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
callback_vad_worker_policy(true, VadBackend::Disabled, false),
|
||||||
|
VadWorkerPolicy::NotModelBacked
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
callback_vad_worker_policy(true, VadBackend::WebrtcVad, false),
|
||||||
|
VadWorkerPolicy::NotModelBacked
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
/// Diagnostics returned by render downmix helpers.
|
/// Diagnostics returned by render downmix helpers.
|
||||||
|
#[cfg(any(target_os = "ios", test))]
|
||||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
|
||||||
pub(crate) struct RenderDownmixStats {
|
pub(crate) struct RenderDownmixStats {
|
||||||
/// Peak absolute sample magnitude after i16 conversion.
|
/// Peak absolute sample magnitude after i16 conversion.
|
||||||
@@ -7,47 +8,7 @@ pub(crate) struct RenderDownmixStats {
|
|||||||
pub clipped_samples: u64,
|
pub clipped_samples: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Downmix interleaved stereo f32 samples into mono i16 samples.
|
|
||||||
///
|
|
||||||
/// The helper is allocation-free and safe for realtime render callbacks.
|
|
||||||
/// If the stereo source is shorter than expected, the remainder of `out`
|
|
||||||
/// is filled with silence.
|
|
||||||
#[cfg(any(target_os = "ios", test))]
|
#[cfg(any(target_os = "ios", test))]
|
||||||
pub(crate) fn downmix_stereo_f32_to_mono_i16(
|
|
||||||
stereo: &[f32],
|
|
||||||
out: &mut [i16],
|
|
||||||
gain: f32,
|
|
||||||
muted: bool,
|
|
||||||
) -> RenderDownmixStats {
|
|
||||||
if muted {
|
|
||||||
out.fill(0);
|
|
||||||
return RenderDownmixStats::default();
|
|
||||||
}
|
|
||||||
|
|
||||||
let available_frames = stereo.len() / 2;
|
|
||||||
if available_frames < out.len() {
|
|
||||||
out.fill(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut peak = 0_u16;
|
|
||||||
let mut clipped_samples = 0_u64;
|
|
||||||
for (dst, lr) in out.iter_mut().zip(stereo.chunks_exact(2)) {
|
|
||||||
let mono = (lr[0] + lr[1]) * 0.5 * gain;
|
|
||||||
let clamped = mono.clamp(-1.0, 1.0);
|
|
||||||
if (mono - clamped).abs() > f32::EPSILON {
|
|
||||||
clipped_samples = clipped_samples.saturating_add(1);
|
|
||||||
}
|
|
||||||
let sample = (clamped * i16::MAX as f32) as i16;
|
|
||||||
*dst = sample;
|
|
||||||
peak = peak.max(sample.unsigned_abs());
|
|
||||||
}
|
|
||||||
|
|
||||||
RenderDownmixStats {
|
|
||||||
peak_i16: peak.min(i16::MAX as u16) as i16,
|
|
||||||
clipped_samples,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn downmix_stereo_f32_to_interleaved_i16(
|
pub(crate) fn downmix_stereo_f32_to_interleaved_i16(
|
||||||
stereo: &[f32],
|
stereo: &[f32],
|
||||||
out: &mut [i16],
|
out: &mut [i16],
|
||||||
@@ -122,10 +83,7 @@ pub(crate) fn limit_peak_inplace(samples: &mut [f32], threshold: f32) -> f32 {
|
|||||||
if threshold <= 0.0 || !threshold.is_finite() {
|
if threshold <= 0.0 || !threshold.is_finite() {
|
||||||
return 1.0;
|
return 1.0;
|
||||||
}
|
}
|
||||||
let peak = samples
|
let peak = samples.iter().map(|s| s.abs()).fold(0.0_f32, f32::max);
|
||||||
.iter()
|
|
||||||
.map(|s| s.abs())
|
|
||||||
.fold(0.0_f32, f32::max);
|
|
||||||
if peak <= threshold {
|
if peak <= threshold {
|
||||||
return 1.0;
|
return 1.0;
|
||||||
}
|
}
|
||||||
@@ -145,7 +103,7 @@ mod tests {
|
|||||||
let stereo = [1.0_f32, 1.0, 0.25, -0.25, -2.0, -2.0];
|
let stereo = [1.0_f32, 1.0, 0.25, -0.25, -2.0, -2.0];
|
||||||
let mut out = [0_i16; 3];
|
let mut out = [0_i16; 3];
|
||||||
|
|
||||||
let stats = downmix_stereo_f32_to_mono_i16(&stereo, &mut out, 2.0, false);
|
let stats = downmix_stereo_f32_to_interleaved_i16(&stereo, &mut out, 1, 2.0, false);
|
||||||
|
|
||||||
assert_eq!(out[0], i16::MAX);
|
assert_eq!(out[0], i16::MAX);
|
||||||
assert_eq!(out[1], 0);
|
assert_eq!(out[1], 0);
|
||||||
@@ -159,7 +117,7 @@ mod tests {
|
|||||||
let stereo = [1.0_f32, 1.0, -1.0, -1.0];
|
let stereo = [1.0_f32, 1.0, -1.0, -1.0];
|
||||||
let mut out = [123_i16; 2];
|
let mut out = [123_i16; 2];
|
||||||
|
|
||||||
let stats = downmix_stereo_f32_to_mono_i16(&stereo, &mut out, 1.0, true);
|
let stats = downmix_stereo_f32_to_interleaved_i16(&stereo, &mut out, 1, 1.0, true);
|
||||||
|
|
||||||
assert_eq!(out, [0, 0]);
|
assert_eq!(out, [0, 0]);
|
||||||
assert_eq!(stats, RenderDownmixStats::default());
|
assert_eq!(stats, RenderDownmixStats::default());
|
||||||
@@ -234,7 +192,7 @@ mod tests {
|
|||||||
let mut scratch = [1.0_f32, 1.0, -0.5, -0.5, 0.8, 0.8];
|
let mut scratch = [1.0_f32, 1.0, -0.5, -0.5, 0.8, 0.8];
|
||||||
limit_peak_inplace(&mut scratch, 0.95);
|
limit_peak_inplace(&mut scratch, 0.95);
|
||||||
let mut out = [0_i16; 3];
|
let mut out = [0_i16; 3];
|
||||||
let stats = downmix_stereo_f32_to_mono_i16(&scratch, &mut out, 1.0, false);
|
let stats = downmix_stereo_f32_to_interleaved_i16(&scratch, &mut out, 1, 1.0, false);
|
||||||
assert_eq!(stats.clipped_samples, 0);
|
assert_eq!(stats.clipped_samples, 0);
|
||||||
assert!(stats.peak_i16 < i16::MAX);
|
assert!(stats.peak_i16 < i16::MAX);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1695,6 +1695,8 @@ pub enum BridgeEvent {
|
|||||||
message: String,
|
message: String,
|
||||||
/// Target scope (server/channel/private/poke).
|
/// Target scope (server/channel/private/poke).
|
||||||
target: BridgeMessageTarget,
|
target: BridgeMessageTarget,
|
||||||
|
/// Poke notification strength, present only for poke messages.
|
||||||
|
poke_strength: Option<BridgePokeStrength>,
|
||||||
},
|
},
|
||||||
/// Human-readable server activity surfaced from protocol bookkeeping events.
|
/// Human-readable server activity surfaced from protocol bookkeeping events.
|
||||||
ServerActivity {
|
ServerActivity {
|
||||||
@@ -1812,6 +1814,27 @@ impl From<chanora_core::MessageTarget> for BridgeMessageTarget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Bridge poke notification strength.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum BridgePokeStrength {
|
||||||
|
/// Poke should be surfaced at full strength.
|
||||||
|
Strong,
|
||||||
|
/// Poke is rate-limited but below overflow severity.
|
||||||
|
Suppressed,
|
||||||
|
/// Poke remains suppressed after repeated suppressed pokes.
|
||||||
|
SuppressedOverflow,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<chanora_core::PokeStrength> for BridgePokeStrength {
|
||||||
|
fn from(strength: chanora_core::PokeStrength) -> Self {
|
||||||
|
match strength {
|
||||||
|
chanora_core::PokeStrength::Strong => Self::Strong,
|
||||||
|
chanora_core::PokeStrength::Suppressed => Self::Suppressed,
|
||||||
|
chanora_core::PokeStrength::SuppressedOverflow => Self::SuppressedOverflow,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Bridge mirror of core join projection sync state.
|
/// Bridge mirror of core join projection sync state.
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
pub enum BridgeVoiceJoinSyncState {
|
pub enum BridgeVoiceJoinSyncState {
|
||||||
@@ -1958,11 +1981,13 @@ impl From<chanora_core::SessionEvent> for BridgeEvent {
|
|||||||
sender_name,
|
sender_name,
|
||||||
message,
|
message,
|
||||||
target,
|
target,
|
||||||
|
poke_strength,
|
||||||
} => BridgeEvent::ChatMessage {
|
} => BridgeEvent::ChatMessage {
|
||||||
sender_id,
|
sender_id,
|
||||||
sender_name,
|
sender_name,
|
||||||
message,
|
message,
|
||||||
target: target.into(),
|
target: target.into(),
|
||||||
|
poke_strength: poke_strength.map(Into::into),
|
||||||
},
|
},
|
||||||
chanora_core::SessionEvent::ServerActivity { message } => {
|
chanora_core::SessionEvent::ServerActivity { message } => {
|
||||||
BridgeEvent::ServerActivity { message }
|
BridgeEvent::ServerActivity { message }
|
||||||
@@ -1972,27 +1997,77 @@ impl From<chanora_core::SessionEvent> for BridgeEvent {
|
|||||||
route: route.into(),
|
route: route.into(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
chanora_core::SessionEvent::ClientMoved { client_id, new_channel_id } => {
|
chanora_core::SessionEvent::ClientMoved {
|
||||||
BridgeEvent::ClientMoved { client_id, new_channel_id }
|
client_id,
|
||||||
}
|
new_channel_id,
|
||||||
chanora_core::SessionEvent::ClientJoined { client_id, channel_id, name, input_muted, output_muted, is_server_query, talk_power, talk_power_granted } => {
|
} => BridgeEvent::ClientMoved {
|
||||||
BridgeEvent::ClientJoined { client_id, channel_id, name, input_muted, output_muted, is_server_query, talk_power, talk_power_granted }
|
client_id,
|
||||||
}
|
new_channel_id,
|
||||||
|
},
|
||||||
|
chanora_core::SessionEvent::ClientJoined {
|
||||||
|
client_id,
|
||||||
|
channel_id,
|
||||||
|
name,
|
||||||
|
input_muted,
|
||||||
|
output_muted,
|
||||||
|
is_server_query,
|
||||||
|
talk_power,
|
||||||
|
talk_power_granted,
|
||||||
|
} => BridgeEvent::ClientJoined {
|
||||||
|
client_id,
|
||||||
|
channel_id,
|
||||||
|
name,
|
||||||
|
input_muted,
|
||||||
|
output_muted,
|
||||||
|
is_server_query,
|
||||||
|
talk_power,
|
||||||
|
talk_power_granted,
|
||||||
|
},
|
||||||
chanora_core::SessionEvent::ClientLeft { client_id, name } => {
|
chanora_core::SessionEvent::ClientLeft { client_id, name } => {
|
||||||
BridgeEvent::ClientLeft { client_id, name }
|
BridgeEvent::ClientLeft { client_id, name }
|
||||||
}
|
}
|
||||||
chanora_core::SessionEvent::ClientUpdated { client_id, input_muted, output_muted, is_server_query, talk_power, talk_power_granted } => {
|
chanora_core::SessionEvent::ClientUpdated {
|
||||||
BridgeEvent::ClientUpdated { client_id, input_muted, output_muted, is_server_query, talk_power, talk_power_granted }
|
client_id,
|
||||||
}
|
input_muted,
|
||||||
chanora_core::SessionEvent::ChannelAdded { id, parent, name, order, has_password, needed_talk_power } => {
|
output_muted,
|
||||||
BridgeEvent::ChannelAdded { id, parent, name, order, has_password, needed_talk_power }
|
is_server_query,
|
||||||
}
|
talk_power,
|
||||||
chanora_core::SessionEvent::ChannelRemoved { id } => {
|
talk_power_granted,
|
||||||
BridgeEvent::ChannelRemoved { id }
|
} => BridgeEvent::ClientUpdated {
|
||||||
}
|
client_id,
|
||||||
chanora_core::SessionEvent::ChannelUpdated { id, name, has_password, needed_talk_power } => {
|
input_muted,
|
||||||
BridgeEvent::ChannelUpdated { id, name, has_password, needed_talk_power }
|
output_muted,
|
||||||
}
|
is_server_query,
|
||||||
|
talk_power,
|
||||||
|
talk_power_granted,
|
||||||
|
},
|
||||||
|
chanora_core::SessionEvent::ChannelAdded {
|
||||||
|
id,
|
||||||
|
parent,
|
||||||
|
name,
|
||||||
|
order,
|
||||||
|
has_password,
|
||||||
|
needed_talk_power,
|
||||||
|
} => BridgeEvent::ChannelAdded {
|
||||||
|
id,
|
||||||
|
parent,
|
||||||
|
name,
|
||||||
|
order,
|
||||||
|
has_password,
|
||||||
|
needed_talk_power,
|
||||||
|
},
|
||||||
|
chanora_core::SessionEvent::ChannelRemoved { id } => BridgeEvent::ChannelRemoved { id },
|
||||||
|
chanora_core::SessionEvent::ChannelUpdated {
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
has_password,
|
||||||
|
needed_talk_power,
|
||||||
|
} => BridgeEvent::ChannelUpdated {
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
has_password,
|
||||||
|
needed_talk_power,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2292,11 +2292,14 @@ impl SseDecode for crate::api::BridgeEvent {
|
|||||||
let mut var_senderName = <String>::sse_decode(deserializer);
|
let mut var_senderName = <String>::sse_decode(deserializer);
|
||||||
let mut var_message = <String>::sse_decode(deserializer);
|
let mut var_message = <String>::sse_decode(deserializer);
|
||||||
let mut var_target = <crate::api::BridgeMessageTarget>::sse_decode(deserializer);
|
let mut var_target = <crate::api::BridgeMessageTarget>::sse_decode(deserializer);
|
||||||
|
let mut var_pokeStrength =
|
||||||
|
<Option<crate::api::BridgePokeStrength>>::sse_decode(deserializer);
|
||||||
return crate::api::BridgeEvent::ChatMessage {
|
return crate::api::BridgeEvent::ChatMessage {
|
||||||
sender_id: var_senderId,
|
sender_id: var_senderId,
|
||||||
sender_name: var_senderName,
|
sender_name: var_senderName,
|
||||||
message: var_message,
|
message: var_message,
|
||||||
target: var_target,
|
target: var_target,
|
||||||
|
poke_strength: var_pokeStrength,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
11 => {
|
11 => {
|
||||||
@@ -2454,6 +2457,19 @@ impl SseDecode for crate::api::BridgeNetworkState {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl SseDecode for crate::api::BridgePokeStrength {
|
||||||
|
// Codec=Sse (Serialization based), see doc to use other codecs
|
||||||
|
fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self {
|
||||||
|
let mut inner = <i32>::sse_decode(deserializer);
|
||||||
|
return match inner {
|
||||||
|
0 => crate::api::BridgePokeStrength::Strong,
|
||||||
|
1 => crate::api::BridgePokeStrength::Suppressed,
|
||||||
|
2 => crate::api::BridgePokeStrength::SuppressedOverflow,
|
||||||
|
_ => unreachable!("Invalid variant for BridgePokeStrength: {}", inner),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl SseDecode for crate::api::BridgePttBinding {
|
impl SseDecode for crate::api::BridgePttBinding {
|
||||||
// Codec=Sse (Serialization based), see doc to use other codecs
|
// Codec=Sse (Serialization based), see doc to use other codecs
|
||||||
fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self {
|
fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self {
|
||||||
@@ -2680,6 +2696,17 @@ impl SseDecode for Option<String> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl SseDecode for Option<crate::api::BridgePokeStrength> {
|
||||||
|
// Codec=Sse (Serialization based), see doc to use other codecs
|
||||||
|
fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self {
|
||||||
|
if (<bool>::sse_decode(deserializer)) {
|
||||||
|
return Some(<crate::api::BridgePokeStrength>::sse_decode(deserializer));
|
||||||
|
} else {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl SseDecode for Option<crate::api::BridgeVoiceJoinErrorCode> {
|
impl SseDecode for Option<crate::api::BridgeVoiceJoinErrorCode> {
|
||||||
// Codec=Sse (Serialization based), see doc to use other codecs
|
// Codec=Sse (Serialization based), see doc to use other codecs
|
||||||
fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self {
|
fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self {
|
||||||
@@ -3299,12 +3326,14 @@ impl flutter_rust_bridge::IntoDart for crate::api::BridgeEvent {
|
|||||||
sender_name,
|
sender_name,
|
||||||
message,
|
message,
|
||||||
target,
|
target,
|
||||||
|
poke_strength,
|
||||||
} => [
|
} => [
|
||||||
10.into_dart(),
|
10.into_dart(),
|
||||||
sender_id.into_into_dart().into_dart(),
|
sender_id.into_into_dart().into_dart(),
|
||||||
sender_name.into_into_dart().into_dart(),
|
sender_name.into_into_dart().into_dart(),
|
||||||
message.into_into_dart().into_dart(),
|
message.into_into_dart().into_dart(),
|
||||||
target.into_into_dart().into_dart(),
|
target.into_into_dart().into_dart(),
|
||||||
|
poke_strength.into_into_dart().into_dart(),
|
||||||
]
|
]
|
||||||
.into_dart(),
|
.into_dart(),
|
||||||
crate::api::BridgeEvent::ServerActivity { message } => {
|
crate::api::BridgeEvent::ServerActivity { message } => {
|
||||||
@@ -3484,6 +3513,28 @@ impl flutter_rust_bridge::IntoIntoDart<crate::api::BridgeNetworkState>
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Codec=Dco (DartCObject based), see doc to use other codecs
|
// Codec=Dco (DartCObject based), see doc to use other codecs
|
||||||
|
impl flutter_rust_bridge::IntoDart for crate::api::BridgePokeStrength {
|
||||||
|
fn into_dart(self) -> flutter_rust_bridge::for_generated::DartAbi {
|
||||||
|
match self {
|
||||||
|
Self::Strong => 0.into_dart(),
|
||||||
|
Self::Suppressed => 1.into_dart(),
|
||||||
|
Self::SuppressedOverflow => 2.into_dart(),
|
||||||
|
_ => unreachable!(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
impl flutter_rust_bridge::for_generated::IntoDartExceptPrimitive
|
||||||
|
for crate::api::BridgePokeStrength
|
||||||
|
{
|
||||||
|
}
|
||||||
|
impl flutter_rust_bridge::IntoIntoDart<crate::api::BridgePokeStrength>
|
||||||
|
for crate::api::BridgePokeStrength
|
||||||
|
{
|
||||||
|
fn into_into_dart(self) -> crate::api::BridgePokeStrength {
|
||||||
|
self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Codec=Dco (DartCObject based), see doc to use other codecs
|
||||||
impl flutter_rust_bridge::IntoDart for crate::api::BridgePttBinding {
|
impl flutter_rust_bridge::IntoDart for crate::api::BridgePttBinding {
|
||||||
fn into_dart(self) -> flutter_rust_bridge::for_generated::DartAbi {
|
fn into_dart(self) -> flutter_rust_bridge::for_generated::DartAbi {
|
||||||
[
|
[
|
||||||
@@ -4053,12 +4104,14 @@ impl SseEncode for crate::api::BridgeEvent {
|
|||||||
sender_name,
|
sender_name,
|
||||||
message,
|
message,
|
||||||
target,
|
target,
|
||||||
|
poke_strength,
|
||||||
} => {
|
} => {
|
||||||
<i32>::sse_encode(10, serializer);
|
<i32>::sse_encode(10, serializer);
|
||||||
<u64>::sse_encode(sender_id, serializer);
|
<u64>::sse_encode(sender_id, serializer);
|
||||||
<String>::sse_encode(sender_name, serializer);
|
<String>::sse_encode(sender_name, serializer);
|
||||||
<String>::sse_encode(message, serializer);
|
<String>::sse_encode(message, serializer);
|
||||||
<crate::api::BridgeMessageTarget>::sse_encode(target, serializer);
|
<crate::api::BridgeMessageTarget>::sse_encode(target, serializer);
|
||||||
|
<Option<crate::api::BridgePokeStrength>>::sse_encode(poke_strength, serializer);
|
||||||
}
|
}
|
||||||
crate::api::BridgeEvent::ServerActivity { message } => {
|
crate::api::BridgeEvent::ServerActivity { message } => {
|
||||||
<i32>::sse_encode(11, serializer);
|
<i32>::sse_encode(11, serializer);
|
||||||
@@ -4214,6 +4267,23 @@ impl SseEncode for crate::api::BridgeNetworkState {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl SseEncode for crate::api::BridgePokeStrength {
|
||||||
|
// Codec=Sse (Serialization based), see doc to use other codecs
|
||||||
|
fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) {
|
||||||
|
<i32>::sse_encode(
|
||||||
|
match self {
|
||||||
|
crate::api::BridgePokeStrength::Strong => 0,
|
||||||
|
crate::api::BridgePokeStrength::Suppressed => 1,
|
||||||
|
crate::api::BridgePokeStrength::SuppressedOverflow => 2,
|
||||||
|
_ => {
|
||||||
|
unimplemented!("");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
serializer,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl SseEncode for crate::api::BridgePttBinding {
|
impl SseEncode for crate::api::BridgePttBinding {
|
||||||
// Codec=Sse (Serialization based), see doc to use other codecs
|
// Codec=Sse (Serialization based), see doc to use other codecs
|
||||||
fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) {
|
fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) {
|
||||||
@@ -4429,6 +4499,16 @@ impl SseEncode for Option<String> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl SseEncode for Option<crate::api::BridgePokeStrength> {
|
||||||
|
// Codec=Sse (Serialization based), see doc to use other codecs
|
||||||
|
fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) {
|
||||||
|
<bool>::sse_encode(self.is_some(), serializer);
|
||||||
|
if let Some(value) = self {
|
||||||
|
<crate::api::BridgePokeStrength>::sse_encode(value, serializer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl SseEncode for Option<crate::api::BridgeVoiceJoinErrorCode> {
|
impl SseEncode for Option<crate::api::BridgeVoiceJoinErrorCode> {
|
||||||
// Codec=Sse (Serialization based), see doc to use other codecs
|
// Codec=Sse (Serialization based), see doc to use other codecs
|
||||||
fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) {
|
fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) {
|
||||||
|
|||||||
@@ -36,7 +36,7 @@
|
|||||||
#![forbid(unsafe_code)]
|
#![forbid(unsafe_code)]
|
||||||
#![warn(missing_docs)]
|
#![warn(missing_docs)]
|
||||||
|
|
||||||
use std::collections::HashSet;
|
use std::collections::{HashSet, VecDeque};
|
||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
use thiserror::Error;
|
use thiserror::Error;
|
||||||
@@ -712,7 +712,7 @@ impl DiagnosticExport {
|
|||||||
/// diagnostic export and state-sync replay verification.
|
/// diagnostic export and state-sync replay verification.
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct ProtocolEventRecorder {
|
pub struct ProtocolEventRecorder {
|
||||||
events: Vec<String>,
|
events: VecDeque<String>,
|
||||||
capacity: usize,
|
capacity: usize,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -720,17 +720,20 @@ impl ProtocolEventRecorder {
|
|||||||
/// Create a recorder with the given ring-buffer capacity.
|
/// Create a recorder with the given ring-buffer capacity.
|
||||||
pub fn new(capacity: usize) -> Self {
|
pub fn new(capacity: usize) -> Self {
|
||||||
Self {
|
Self {
|
||||||
events: Vec::with_capacity(capacity),
|
events: VecDeque::with_capacity(capacity),
|
||||||
capacity,
|
capacity,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn push(&mut self, ts: &str, kind: &str, detail: &str) {
|
fn push(&mut self, ts: &str, kind: &str, detail: &str) {
|
||||||
|
if self.capacity == 0 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
let s = format!("[{ts}] {kind}: {detail}");
|
let s = format!("[{ts}] {kind}: {detail}");
|
||||||
if self.events.len() >= self.capacity {
|
if self.events.len() >= self.capacity {
|
||||||
self.events.remove(0);
|
self.events.pop_front();
|
||||||
}
|
}
|
||||||
self.events.push(s);
|
self.events.push_back(s);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Record a successful connection.
|
/// Record a successful connection.
|
||||||
@@ -777,12 +780,12 @@ impl ProtocolEventRecorder {
|
|||||||
|
|
||||||
/// Drain all recorded events and reset the buffer.
|
/// Drain all recorded events and reset the buffer.
|
||||||
pub fn drain(&mut self) -> Vec<String> {
|
pub fn drain(&mut self) -> Vec<String> {
|
||||||
std::mem::take(&mut self.events)
|
self.events.drain(..).collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Snapshot all recorded events without clearing the buffer.
|
/// Snapshot all recorded events without clearing the buffer.
|
||||||
pub fn snapshot(&self) -> Vec<String> {
|
pub fn snapshot(&self) -> Vec<String> {
|
||||||
self.events.clone()
|
self.events.iter().cloned().collect()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1103,4 +1106,13 @@ mod tests {
|
|||||||
assert_eq!(first, second);
|
assert_eq!(first, second);
|
||||||
assert_eq!(drained, first);
|
assert_eq!(drained, first);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn protocol_event_zero_capacity_drops_events() {
|
||||||
|
let mut recorder = ProtocolEventRecorder::new(0);
|
||||||
|
recorder.record_connected("Server");
|
||||||
|
|
||||||
|
assert!(recorder.snapshot().is_empty());
|
||||||
|
assert!(recorder.drain().is_empty());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,11 +41,15 @@ use crate::dto::{
|
|||||||
ChannelId, ChannelInfo, ChatMessage, ClientId, ClientInfo, ClientProfile, MessageTarget,
|
ChannelId, ChannelInfo, ChatMessage, ClientId, ClientInfo, ClientProfile, MessageTarget,
|
||||||
ProtocolDelta, ServerActivity, ServerSnapshot,
|
ProtocolDelta, ServerActivity, ServerSnapshot,
|
||||||
};
|
};
|
||||||
|
use crate::poke_limiter::PokeLimiter;
|
||||||
use crate::ProtocolError;
|
use crate::ProtocolError;
|
||||||
|
|
||||||
const SPEAKING_ACTIVITY_WINDOW: Duration = Duration::from_millis(750);
|
const SPEAKING_ACTIVITY_WINDOW: Duration = Duration::from_millis(750);
|
||||||
const INBOUND_VOICE_SEND_TIMEOUT: Duration = Duration::from_millis(40);
|
const INBOUND_VOICE_SEND_TIMEOUT: Duration = Duration::from_millis(40);
|
||||||
const PROFILE_REFRESH_RESULT_TIMEOUT: Duration = Duration::from_secs(3);
|
const PROFILE_REFRESH_RESULT_TIMEOUT: Duration = Duration::from_secs(3);
|
||||||
|
const OUTBOUND_VOICE_PACKETS_PER_TICK: usize = 8;
|
||||||
|
const DISCONNECT_REPLY_TIMEOUT: Duration = Duration::from_secs(1);
|
||||||
|
const DISCONNECT_EVENT_DRAIN_TIMEOUT: Duration = Duration::from_millis(500);
|
||||||
|
|
||||||
type PendingMoves = HashMap<
|
type PendingMoves = HashMap<
|
||||||
MessageHandle,
|
MessageHandle,
|
||||||
@@ -84,6 +88,30 @@ async fn send_with_timeout<T: Send>(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn drain_voice_packets_for_tick<T, E>(
|
||||||
|
voice_out_rx: &mut mpsc::Receiver<T>,
|
||||||
|
max_packets: usize,
|
||||||
|
mut send: impl FnMut(T) -> Result<(), E>,
|
||||||
|
) -> usize {
|
||||||
|
let mut drained = 0;
|
||||||
|
for _ in 0..max_packets {
|
||||||
|
let packet = match voice_out_rx.try_recv() {
|
||||||
|
Ok(packet) => packet,
|
||||||
|
Err(_) => break,
|
||||||
|
};
|
||||||
|
let _ = send(packet);
|
||||||
|
drained += 1;
|
||||||
|
}
|
||||||
|
drained
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn bounded_drain_stream<S>(stream: S, timeout_duration: Duration)
|
||||||
|
where
|
||||||
|
S: futures::Stream,
|
||||||
|
{
|
||||||
|
let _ = tokio::time::timeout(timeout_duration, stream.for_each(|_| future::ready(()))).await;
|
||||||
|
}
|
||||||
|
|
||||||
/// Pick the TeamSpeak `client_version`/platform/signature triple
|
/// Pick the TeamSpeak `client_version`/platform/signature triple
|
||||||
/// (sourced from `ReSpeak/tsdeclarations/Versions.csv`, baked into
|
/// (sourced from `ReSpeak/tsdeclarations/Versions.csv`, baked into
|
||||||
/// `tsproto-types` at vendor-time) that best matches the *runtime*
|
/// `tsproto-types` at vendor-time) that best matches the *runtime*
|
||||||
@@ -341,8 +369,21 @@ impl ProtocolClient {
|
|||||||
/// Disconnect cleanly. Blocks until the task exits.
|
/// Disconnect cleanly. Blocks until the task exits.
|
||||||
pub async fn disconnect(self) {
|
pub async fn disconnect(self) {
|
||||||
let (tx, rx) = oneshot::channel();
|
let (tx, rx) = oneshot::channel();
|
||||||
if self.tx.send(Request::Disconnect(tx)).await.is_ok() {
|
let request_path = async {
|
||||||
let _ = rx.await;
|
if self.tx.send(Request::Disconnect(tx)).await.is_ok() {
|
||||||
|
let _ = rx.await;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if tokio::time::timeout(DISCONNECT_REPLY_TIMEOUT, request_path)
|
||||||
|
.await
|
||||||
|
.is_err()
|
||||||
|
{
|
||||||
|
warn!(
|
||||||
|
target: "chanora_protocol",
|
||||||
|
timeout_ms = DISCONNECT_REPLY_TIMEOUT.as_millis() as u64,
|
||||||
|
"disconnect request did not complete before timeout"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -673,15 +714,18 @@ async fn connection_task(
|
|||||||
// reply channel — at most 3 s of pending state per move.
|
// reply channel — at most 3 s of pending state per move.
|
||||||
let mut pending_moves: PendingMoves = HashMap::new();
|
let mut pending_moves: PendingMoves = HashMap::new();
|
||||||
let mut voice_activity: HashMap<u64, Instant> = HashMap::new();
|
let mut voice_activity: HashMap<u64, Instant> = HashMap::new();
|
||||||
|
let mut poke_limiter = PokeLimiter::new();
|
||||||
|
|
||||||
// Main loop: pump events, service requests, forward voice.
|
// Main loop: pump events, service requests, forward voice.
|
||||||
loop {
|
loop {
|
||||||
// 1. Drain any outbound voice packets first — they're time-sensitive.
|
// 1. Send a bounded batch of outbound voice packets first — they're
|
||||||
while let Ok(pkt) = voice_out_rx.try_recv() {
|
// time-sensitive, but control requests must still make progress.
|
||||||
|
drain_voice_packets_for_tick(&mut voice_out_rx, OUTBOUND_VOICE_PACKETS_PER_TICK, |pkt| {
|
||||||
if let Err(e) = con.send_audio(pkt) {
|
if let Err(e) = con.send_audio(pkt) {
|
||||||
warn!(target: "chanora_protocol", error = %e, "send_audio failed");
|
warn!(target: "chanora_protocol", error = %e, "send_audio failed");
|
||||||
}
|
}
|
||||||
}
|
Ok::<(), ()>(())
|
||||||
|
});
|
||||||
|
|
||||||
// 2. Advance event stream by at most one event with a small timeout.
|
// 2. Advance event stream by at most one event with a small timeout.
|
||||||
let pump = async {
|
let pump = async {
|
||||||
@@ -689,21 +733,20 @@ async fn connection_task(
|
|||||||
tokio::time::timeout(Duration::from_millis(20), ev_stream.next()).await
|
tokio::time::timeout(Duration::from_millis(20), ev_stream.next()).await
|
||||||
};
|
};
|
||||||
match pump.await {
|
match pump.await {
|
||||||
Ok(Some(Ok(item))) => {
|
Ok(Some(Ok(item))) => match item {
|
||||||
match item {
|
StreamItem::Audio(buf) => {
|
||||||
StreamItem::Audio(buf) => {
|
handle_audio_stream_item(&channels.voice_in, &mut voice_activity, buf).await;
|
||||||
handle_audio_stream_item(&channels.voice_in, &mut voice_activity, buf).await;
|
|
||||||
}
|
|
||||||
other => handle_non_audio_stream_item(
|
|
||||||
&con,
|
|
||||||
other,
|
|
||||||
&channels.chat,
|
|
||||||
&channels.activity,
|
|
||||||
&channels.delta,
|
|
||||||
&mut pending_moves,
|
|
||||||
),
|
|
||||||
}
|
}
|
||||||
}
|
other => handle_non_audio_stream_item(
|
||||||
|
&con,
|
||||||
|
other,
|
||||||
|
&channels.chat,
|
||||||
|
&channels.activity,
|
||||||
|
&channels.delta,
|
||||||
|
&mut pending_moves,
|
||||||
|
&mut poke_limiter,
|
||||||
|
),
|
||||||
|
},
|
||||||
Ok(Some(Err(e))) => {
|
Ok(Some(Err(e))) => {
|
||||||
warn!(target: "chanora_protocol", error = %e, "event error");
|
warn!(target: "chanora_protocol", error = %e, "event error");
|
||||||
// Some errors are transient; treat persistent ones
|
// Some errors are transient; treat persistent ones
|
||||||
@@ -801,13 +844,14 @@ async fn connection_task(
|
|||||||
&channels,
|
&channels,
|
||||||
&mut pending_moves,
|
&mut pending_moves,
|
||||||
&mut voice_activity,
|
&mut voice_activity,
|
||||||
|
&mut poke_limiter,
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
let _ = reply.send(r);
|
let _ = reply.send(r);
|
||||||
}
|
}
|
||||||
Ok(Request::Disconnect(reply)) => {
|
Ok(Request::Disconnect(reply)) => {
|
||||||
let _ = con.disconnect(DisconnectOptions::new());
|
let _ = con.disconnect(DisconnectOptions::new());
|
||||||
con.events().for_each(|_| future::ready(())).await;
|
bounded_drain_stream(con.events(), DISCONNECT_EVENT_DRAIN_TIMEOUT).await;
|
||||||
let _ = reply.send(());
|
let _ = reply.send(());
|
||||||
info!(target: "chanora_protocol", "clean disconnect");
|
info!(target: "chanora_protocol", "clean disconnect");
|
||||||
exit!(DisconnectReason::UserRequested);
|
exit!(DisconnectReason::UserRequested);
|
||||||
@@ -815,7 +859,7 @@ async fn connection_task(
|
|||||||
Err(mpsc::error::TryRecvError::Empty) => {}
|
Err(mpsc::error::TryRecvError::Empty) => {}
|
||||||
Err(mpsc::error::TryRecvError::Disconnected) => {
|
Err(mpsc::error::TryRecvError::Disconnected) => {
|
||||||
let _ = con.disconnect(DisconnectOptions::new());
|
let _ = con.disconnect(DisconnectOptions::new());
|
||||||
con.events().for_each(|_| future::ready(())).await;
|
bounded_drain_stream(con.events(), DISCONNECT_EVENT_DRAIN_TIMEOUT).await;
|
||||||
info!(target: "chanora_protocol", "handle dropped; implicit disconnect");
|
info!(target: "chanora_protocol", "handle dropped; implicit disconnect");
|
||||||
exit!(DisconnectReason::UserRequested);
|
exit!(DisconnectReason::UserRequested);
|
||||||
}
|
}
|
||||||
@@ -863,6 +907,7 @@ fn handle_non_audio_stream_item(
|
|||||||
activity_tx: &mpsc::Sender<ServerActivity>,
|
activity_tx: &mpsc::Sender<ServerActivity>,
|
||||||
delta_tx: &mpsc::Sender<ProtocolDelta>,
|
delta_tx: &mpsc::Sender<ProtocolDelta>,
|
||||||
pending_moves: &mut PendingMoves,
|
pending_moves: &mut PendingMoves,
|
||||||
|
poke_limiter: &mut PokeLimiter,
|
||||||
) {
|
) {
|
||||||
match item {
|
match item {
|
||||||
StreamItem::BookEvents(events) => {
|
StreamItem::BookEvents(events) => {
|
||||||
@@ -924,17 +969,25 @@ fn handle_non_audio_stream_item(
|
|||||||
message,
|
message,
|
||||||
} = ev
|
} = ev
|
||||||
{
|
{
|
||||||
let mapped = match target {
|
let (mapped, poke_strength) = match target {
|
||||||
tsclientlib::MessageTarget::Server => MessageTarget::Server,
|
tsclientlib::MessageTarget::Server => (MessageTarget::Server, None),
|
||||||
tsclientlib::MessageTarget::Channel => MessageTarget::Channel,
|
tsclientlib::MessageTarget::Channel => (MessageTarget::Channel, None),
|
||||||
tsclientlib::MessageTarget::Client(id) => MessageTarget::Client(id.0 as u64),
|
tsclientlib::MessageTarget::Client(id) => {
|
||||||
tsclientlib::MessageTarget::Poke(id) => MessageTarget::Poke(id.0 as u64),
|
(MessageTarget::Client(id.0 as u64), None)
|
||||||
|
}
|
||||||
|
tsclientlib::MessageTarget::Poke(id) => {
|
||||||
|
let own_client_id =
|
||||||
|
con.get_state().ok().map(|state| state.own_client.0 as u64);
|
||||||
|
let strength = poke_limiter.record(invoker.id.0 as u64, own_client_id);
|
||||||
|
(MessageTarget::Poke(id.0 as u64), Some(strength))
|
||||||
|
}
|
||||||
};
|
};
|
||||||
let _ = chat_tx.try_send(ChatMessage {
|
let _ = chat_tx.try_send(ChatMessage {
|
||||||
sender_id: ClientId(invoker.id.0 as u64),
|
sender_id: ClientId(invoker.id.0 as u64),
|
||||||
sender_name: sanitize(&invoker.name),
|
sender_name: sanitize(&invoker.name),
|
||||||
message: sanitize(&message),
|
message: sanitize(&message),
|
||||||
target: mapped,
|
target: mapped,
|
||||||
|
poke_strength,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1122,10 +1175,19 @@ async fn fetch_client_profile(
|
|||||||
channels: &EventChannels,
|
channels: &EventChannels,
|
||||||
pending_moves: &mut PendingMoves,
|
pending_moves: &mut PendingMoves,
|
||||||
voice_activity: &mut HashMap<u64, Instant>,
|
voice_activity: &mut HashMap<u64, Instant>,
|
||||||
|
poke_limiter: &mut PokeLimiter,
|
||||||
) -> Result<ClientProfile, ProtocolError> {
|
) -> Result<ClientProfile, ProtocolError> {
|
||||||
let target_id = TsClientId(client_id as u16);
|
let target_id = TsClientId(client_id as u16);
|
||||||
|
|
||||||
let (database_id, uid_b64, has_optional, has_connection, is_own, needs_server_groups, needs_channel_groups) = {
|
let (
|
||||||
|
database_id,
|
||||||
|
uid_b64,
|
||||||
|
has_optional,
|
||||||
|
has_connection,
|
||||||
|
is_own,
|
||||||
|
needs_server_groups,
|
||||||
|
needs_channel_groups,
|
||||||
|
) = {
|
||||||
let state = con
|
let state = con
|
||||||
.get_state()
|
.get_state()
|
||||||
.map_err(|e| ProtocolError::Backend(format!("get_state: {e}")))?;
|
.map_err(|e| ProtocolError::Backend(format!("get_state: {e}")))?;
|
||||||
@@ -1159,6 +1221,7 @@ async fn fetch_client_profile(
|
|||||||
channels,
|
channels,
|
||||||
pending_moves,
|
pending_moves,
|
||||||
voice_activity,
|
voice_activity,
|
||||||
|
poke_limiter,
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
}
|
}
|
||||||
@@ -1169,6 +1232,7 @@ async fn fetch_client_profile(
|
|||||||
channels,
|
channels,
|
||||||
pending_moves,
|
pending_moves,
|
||||||
voice_activity,
|
voice_activity,
|
||||||
|
poke_limiter,
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
}
|
}
|
||||||
@@ -1183,6 +1247,7 @@ async fn fetch_client_profile(
|
|||||||
channels,
|
channels,
|
||||||
pending_moves,
|
pending_moves,
|
||||||
voice_activity,
|
voice_activity,
|
||||||
|
poke_limiter,
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
@@ -1201,6 +1266,7 @@ async fn fetch_client_profile(
|
|||||||
channels,
|
channels,
|
||||||
pending_moves,
|
pending_moves,
|
||||||
voice_activity,
|
voice_activity,
|
||||||
|
poke_limiter,
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
@@ -1220,6 +1286,7 @@ async fn fetch_client_profile(
|
|||||||
channels,
|
channels,
|
||||||
pending_moves,
|
pending_moves,
|
||||||
voice_activity,
|
voice_activity,
|
||||||
|
poke_limiter,
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.ok()
|
.ok()
|
||||||
@@ -1289,10 +1356,18 @@ async fn fetch_client_profile(
|
|||||||
.or_else(|| db_info.as_ref().map(|info| info.created.unix_timestamp())),
|
.or_else(|| db_info.as_ref().map(|info| info.created.unix_timestamp())),
|
||||||
last_connected_unix_seconds: optional
|
last_connected_unix_seconds: optional
|
||||||
.map(|info| info.last_connected.unix_timestamp())
|
.map(|info| info.last_connected.unix_timestamp())
|
||||||
.or_else(|| db_info.as_ref().map(|info| info.last_connected.unix_timestamp())),
|
.or_else(|| {
|
||||||
|
db_info
|
||||||
|
.as_ref()
|
||||||
|
.map(|info| info.last_connected.unix_timestamp())
|
||||||
|
}),
|
||||||
connections_total: optional
|
connections_total: optional
|
||||||
.map(|info| u64::from(info.connections_total))
|
.map(|info| u64::from(info.connections_total))
|
||||||
.or_else(|| db_info.as_ref().map(|info| u64::from(info.connections_total))),
|
.or_else(|| {
|
||||||
|
db_info
|
||||||
|
.as_ref()
|
||||||
|
.map(|info| u64::from(info.connections_total))
|
||||||
|
}),
|
||||||
online_seconds: connection
|
online_seconds: connection
|
||||||
.and_then(|info| info.connected_time.map(|duration| duration.whole_seconds())),
|
.and_then(|info| info.connected_time.map(|duration| duration.whole_seconds())),
|
||||||
idle_milliseconds: connection.map(|info| duration_millis(info.idle_time)),
|
idle_milliseconds: connection.map(|info| duration_millis(info.idle_time)),
|
||||||
@@ -1325,14 +1400,10 @@ async fn fetch_client_profile(
|
|||||||
.or_else(|| db_info.as_ref().map(|info| info.bytes_uploaded_total)),
|
.or_else(|| db_info.as_ref().map(|info| info.bytes_uploaded_total)),
|
||||||
packet_loss_client_to_server_total: net_stats
|
packet_loss_client_to_server_total: net_stats
|
||||||
.map(|s| s.get_packetloss())
|
.map(|s| s.get_packetloss())
|
||||||
.or_else(|| {
|
.or_else(|| connection.map(|info| info.client_to_server_packetloss_total)),
|
||||||
connection.map(|info| info.client_to_server_packetloss_total)
|
|
||||||
}),
|
|
||||||
packet_loss_server_to_client_total: net_stats
|
packet_loss_server_to_client_total: net_stats
|
||||||
.map(|s| s.get_packetloss_s2c_total())
|
.map(|s| s.get_packetloss_s2c_total())
|
||||||
.or_else(|| {
|
.or_else(|| connection.and_then(|info| info.server_to_client_packetloss_total)),
|
||||||
connection.and_then(|info| info.server_to_client_packetloss_total)
|
|
||||||
}),
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1378,6 +1449,7 @@ async fn request_messages(
|
|||||||
channels: &EventChannels,
|
channels: &EventChannels,
|
||||||
pending_moves: &mut PendingMoves,
|
pending_moves: &mut PendingMoves,
|
||||||
voice_activity: &mut HashMap<u64, Instant>,
|
voice_activity: &mut HashMap<u64, Instant>,
|
||||||
|
poke_limiter: &mut PokeLimiter,
|
||||||
) -> Result<Vec<InMessage>, ProtocolError> {
|
) -> Result<Vec<InMessage>, ProtocolError> {
|
||||||
let handle = command
|
let handle = command
|
||||||
.send_with_result(con)
|
.send_with_result(con)
|
||||||
@@ -1420,6 +1492,7 @@ async fn request_messages(
|
|||||||
&channels.activity,
|
&channels.activity,
|
||||||
&channels.delta,
|
&channels.delta,
|
||||||
pending_moves,
|
pending_moves,
|
||||||
|
poke_limiter,
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1431,6 +1504,7 @@ async fn request_client_db_info(
|
|||||||
channels: &EventChannels,
|
channels: &EventChannels,
|
||||||
pending_moves: &mut PendingMoves,
|
pending_moves: &mut PendingMoves,
|
||||||
voice_activity: &mut HashMap<u64, Instant>,
|
voice_activity: &mut HashMap<u64, Instant>,
|
||||||
|
poke_limiter: &mut PokeLimiter,
|
||||||
) -> Result<InClientDbInfoPart, ProtocolError> {
|
) -> Result<InClientDbInfoPart, ProtocolError> {
|
||||||
let messages = request_messages(
|
let messages = request_messages(
|
||||||
con,
|
con,
|
||||||
@@ -1438,6 +1512,7 @@ async fn request_client_db_info(
|
|||||||
channels,
|
channels,
|
||||||
pending_moves,
|
pending_moves,
|
||||||
voice_activity,
|
voice_activity,
|
||||||
|
poke_limiter,
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
for message in messages {
|
for message in messages {
|
||||||
@@ -1876,10 +1951,12 @@ const _: () = {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::{
|
use super::{
|
||||||
client_profile_refresh_plan, is_server_query_client_type, send_with_timeout,
|
bounded_drain_stream, client_profile_refresh_plan, drain_voice_packets_for_tick,
|
||||||
server_socket_from_config, sort_channels_tree_by, std_duration_millis,
|
is_server_query_client_type, send_with_timeout, server_socket_from_config,
|
||||||
ConnectConfig, SendTimeoutError,
|
sort_channels_tree_by, std_duration_millis, ConnectConfig, ProtocolClient, Request,
|
||||||
|
SendTimeoutError, DISCONNECT_REPLY_TIMEOUT,
|
||||||
};
|
};
|
||||||
|
use futures::stream;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
use tokio::sync::mpsc;
|
use tokio::sync::mpsc;
|
||||||
use tsproto_types::ClientType;
|
use tsproto_types::ClientType;
|
||||||
@@ -2122,6 +2199,83 @@ mod tests {
|
|||||||
|
|
||||||
assert_eq!(result, Err(SendTimeoutError::Timeout(2)));
|
assert_eq!(result, Err(SendTimeoutError::Timeout(2)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn disconnect_request_send_is_bounded_when_request_channel_is_full() {
|
||||||
|
let (tx, _rx) = mpsc::channel(1);
|
||||||
|
let (reply_tx, _reply_rx) = tokio::sync::oneshot::channel();
|
||||||
|
tx.send(Request::Snapshot(reply_tx))
|
||||||
|
.await
|
||||||
|
.expect("seed first request");
|
||||||
|
|
||||||
|
let (disconnect_tx, _disconnect_rx) = tokio::sync::oneshot::channel();
|
||||||
|
let result = send_with_timeout(
|
||||||
|
&tx,
|
||||||
|
Request::Disconnect(disconnect_tx),
|
||||||
|
Duration::from_millis(10),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
assert!(matches!(result, Err(SendTimeoutError::Timeout(_))));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn protocol_client_disconnect_returns_when_request_channel_is_full() {
|
||||||
|
let (tx, _rx) = mpsc::channel(1);
|
||||||
|
let (snapshot_tx, _snapshot_rx) = tokio::sync::oneshot::channel();
|
||||||
|
tx.send(Request::Snapshot(snapshot_tx))
|
||||||
|
.await
|
||||||
|
.expect("seed first request");
|
||||||
|
let (voice_out_tx, _voice_out_rx) = mpsc::channel(1);
|
||||||
|
let (_voice_in_tx, voice_in_rx) = mpsc::channel(1);
|
||||||
|
let (_lost_tx, lost_rx) = tokio::sync::oneshot::channel();
|
||||||
|
let (_chat_tx, chat_rx) = mpsc::channel(1);
|
||||||
|
let (_activity_tx, activity_rx) = mpsc::channel(1);
|
||||||
|
let (_delta_tx, delta_rx) = mpsc::channel(1);
|
||||||
|
let client = ProtocolClient {
|
||||||
|
tx,
|
||||||
|
voice_out_tx,
|
||||||
|
voice_in_rx: std::sync::Mutex::new(Some(voice_in_rx)),
|
||||||
|
lost_rx: std::sync::Mutex::new(Some(lost_rx)),
|
||||||
|
chat_rx: std::sync::Mutex::new(Some(chat_rx)),
|
||||||
|
activity_rx: std::sync::Mutex::new(Some(activity_rx)),
|
||||||
|
delta_rx: std::sync::Mutex::new(Some(delta_rx)),
|
||||||
|
};
|
||||||
|
|
||||||
|
tokio::time::timeout(
|
||||||
|
DISCONNECT_REPLY_TIMEOUT + Duration::from_millis(100),
|
||||||
|
client.disconnect(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("disconnect should not wait indefinitely for request channel capacity");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn voice_drain_stops_at_per_tick_budget() {
|
||||||
|
let (tx, mut rx) = mpsc::channel(8);
|
||||||
|
for value in 0_u8..5 {
|
||||||
|
tx.send(value).await.expect("seed voice packet");
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut sent = Vec::new();
|
||||||
|
let drained = drain_voice_packets_for_tick(&mut rx, 2, |value| {
|
||||||
|
sent.push(value);
|
||||||
|
Ok::<(), ()>(())
|
||||||
|
});
|
||||||
|
|
||||||
|
assert_eq!(drained, 2);
|
||||||
|
assert_eq!(sent, vec![0, 1]);
|
||||||
|
assert_eq!(rx.len(), 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn disconnect_stream_drain_returns_after_timeout() {
|
||||||
|
let start = tokio::time::Instant::now();
|
||||||
|
|
||||||
|
bounded_drain_stream(stream::pending::<()>(), Duration::from_millis(10)).await;
|
||||||
|
|
||||||
|
assert!(start.elapsed() < Duration::from_millis(100));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn forward_delta(
|
fn forward_delta(
|
||||||
@@ -2204,9 +2358,7 @@ fn forward_delta(
|
|||||||
old: PropertyValue::Channel(channel),
|
old: PropertyValue::Channel(channel),
|
||||||
..
|
..
|
||||||
} => {
|
} => {
|
||||||
let _ = delta_tx.try_send(ProtocolDelta::ChannelRemoved {
|
let _ = delta_tx.try_send(ProtocolDelta::ChannelRemoved { id: channel.id.0 });
|
||||||
id: channel.id.0,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
Event::PropertyChanged {
|
Event::PropertyChanged {
|
||||||
id: PropertyId::Channel(channel_id),
|
id: PropertyId::Channel(channel_id),
|
||||||
|
|||||||
@@ -3,6 +3,8 @@
|
|||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
pub use crate::poke_limiter::PokeStrength;
|
||||||
|
|
||||||
/// Opaque server-side channel identifier. Internal representation is
|
/// Opaque server-side channel identifier. Internal representation is
|
||||||
/// the upstream u64 but callers must treat it as opaque.
|
/// the upstream u64 but callers must treat it as opaque.
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||||
@@ -54,6 +56,8 @@ pub struct ChatMessage {
|
|||||||
pub message: String,
|
pub message: String,
|
||||||
/// Target scope of this message.
|
/// Target scope of this message.
|
||||||
pub target: MessageTarget,
|
pub target: MessageTarget,
|
||||||
|
/// Strength classification for poke notifications.
|
||||||
|
pub poke_strength: Option<PokeStrength>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A server-activity notification derived from TeamSpeak bookkeeping events.
|
/// A server-activity notification derived from TeamSpeak bookkeeping events.
|
||||||
|
|||||||
@@ -35,12 +35,14 @@
|
|||||||
|
|
||||||
mod adapter;
|
mod adapter;
|
||||||
mod dto;
|
mod dto;
|
||||||
|
pub mod poke_limiter;
|
||||||
|
|
||||||
pub use adapter::{ConnectConfig, DisconnectReason, InboundVoice, ProtocolClient, SnapshotProbe};
|
pub use adapter::{ConnectConfig, DisconnectReason, InboundVoice, ProtocolClient, SnapshotProbe};
|
||||||
pub use dto::{
|
pub use dto::{
|
||||||
ChannelId, ChannelInfo, ChatMessage, ClientId, ClientInfo, ClientProfile, MessageTarget,
|
ChannelId, ChannelInfo, ChatMessage, ClientId, ClientInfo, ClientProfile, MessageTarget,
|
||||||
ProtocolDelta, ServerActivity, ServerSnapshot,
|
PokeStrength, ProtocolDelta, ServerActivity, ServerSnapshot,
|
||||||
};
|
};
|
||||||
|
pub use poke_limiter::PokeLimiter;
|
||||||
|
|
||||||
// Re-export the upstream voice types so chanora_audio can build outbound
|
// Re-export the upstream voice types so chanora_audio can build outbound
|
||||||
// voice packets without taking a direct dependency on tsclientlib /
|
// voice packets without taking a direct dependency on tsclientlib /
|
||||||
|
|||||||
@@ -0,0 +1,174 @@
|
|||||||
|
//! Per-connection poke strength classification.
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
/// Notification strength assigned to an inbound poke.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
||||||
|
pub enum PokeStrength {
|
||||||
|
/// Poke should be surfaced at full strength.
|
||||||
|
Strong,
|
||||||
|
/// Poke is rate-limited but below overflow severity.
|
||||||
|
Suppressed,
|
||||||
|
/// Poke remains suppressed after repeated suppressed pokes.
|
||||||
|
SuppressedOverflow,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Per-connection poke limiter.
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct PokeLimiter {
|
||||||
|
window: Duration,
|
||||||
|
entries: HashMap<u64, PokeEntry>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
struct PokeEntry {
|
||||||
|
tokens: u8,
|
||||||
|
last_refill: Instant,
|
||||||
|
suppressed_in_window: u8,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PokeLimiter {
|
||||||
|
const CAPACITY: u8 = 2;
|
||||||
|
const OVERFLOW_THRESHOLD: u8 = 3;
|
||||||
|
|
||||||
|
/// Create a limiter using the default five-minute refill interval.
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
window: Duration::from_secs(5 * 60),
|
||||||
|
entries: HashMap::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Record a poke at the current instant.
|
||||||
|
pub fn record(&mut self, sender_id: u64, own_client_id: Option<u64>) -> PokeStrength {
|
||||||
|
self.record_at(sender_id, own_client_id, Instant::now())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Record a poke at an injected instant.
|
||||||
|
pub fn record_at(
|
||||||
|
&mut self,
|
||||||
|
sender_id: u64,
|
||||||
|
own_client_id: Option<u64>,
|
||||||
|
now: Instant,
|
||||||
|
) -> PokeStrength {
|
||||||
|
if own_client_id == Some(sender_id) {
|
||||||
|
return PokeStrength::Suppressed;
|
||||||
|
}
|
||||||
|
|
||||||
|
let entry = self.entries.entry(sender_id).or_insert(PokeEntry {
|
||||||
|
tokens: Self::CAPACITY,
|
||||||
|
last_refill: now,
|
||||||
|
suppressed_in_window: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
if now.duration_since(entry.last_refill) >= self.window {
|
||||||
|
entry.tokens = Self::CAPACITY;
|
||||||
|
entry.last_refill = now;
|
||||||
|
entry.suppressed_in_window = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
if entry.tokens > 0 {
|
||||||
|
entry.tokens -= 1;
|
||||||
|
return PokeStrength::Strong;
|
||||||
|
}
|
||||||
|
|
||||||
|
entry.suppressed_in_window = entry.suppressed_in_window.saturating_add(1);
|
||||||
|
if entry.suppressed_in_window >= Self::OVERFLOW_THRESHOLD {
|
||||||
|
PokeStrength::SuppressedOverflow
|
||||||
|
} else {
|
||||||
|
PokeStrength::Suppressed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for PokeLimiter {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn first_two_pokes_are_strong_third_is_suppressed() {
|
||||||
|
let mut limiter = PokeLimiter::new();
|
||||||
|
let now = Instant::now();
|
||||||
|
|
||||||
|
assert_eq!(limiter.record_at(7, Some(1), now), PokeStrength::Strong);
|
||||||
|
assert_eq!(
|
||||||
|
limiter.record_at(7, Some(1), now + Duration::from_secs(1)),
|
||||||
|
PokeStrength::Strong
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
limiter.record_at(7, Some(1), now + Duration::from_secs(2)),
|
||||||
|
PokeStrength::Suppressed
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn self_poke_is_suppressed_without_consuming_token() {
|
||||||
|
let mut limiter = PokeLimiter::new();
|
||||||
|
let now = Instant::now();
|
||||||
|
|
||||||
|
assert_eq!(limiter.record_at(7, Some(7), now), PokeStrength::Suppressed);
|
||||||
|
assert_eq!(
|
||||||
|
limiter.record_at(7, Some(1), now + Duration::from_secs(1)),
|
||||||
|
PokeStrength::Strong
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
limiter.record_at(7, Some(1), now + Duration::from_secs(2)),
|
||||||
|
PokeStrength::Strong
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn overflow_after_three_suppressed_pokes_in_five_minutes() {
|
||||||
|
let mut limiter = PokeLimiter::new();
|
||||||
|
let now = Instant::now();
|
||||||
|
|
||||||
|
assert_eq!(limiter.record_at(7, Some(1), now), PokeStrength::Strong);
|
||||||
|
assert_eq!(
|
||||||
|
limiter.record_at(7, Some(1), now + Duration::from_secs(1)),
|
||||||
|
PokeStrength::Strong
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
limiter.record_at(7, Some(1), now + Duration::from_secs(2)),
|
||||||
|
PokeStrength::Suppressed
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
limiter.record_at(7, Some(1), now + Duration::from_secs(3)),
|
||||||
|
PokeStrength::Suppressed
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
limiter.record_at(7, Some(1), now + Duration::from_secs(4)),
|
||||||
|
PokeStrength::SuppressedOverflow
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn refill_after_interval_uses_injected_time_without_sleeping() {
|
||||||
|
let mut limiter = PokeLimiter::new();
|
||||||
|
let now = Instant::now();
|
||||||
|
|
||||||
|
assert_eq!(limiter.record_at(7, Some(1), now), PokeStrength::Strong);
|
||||||
|
assert_eq!(
|
||||||
|
limiter.record_at(7, Some(1), now + Duration::from_secs(1)),
|
||||||
|
PokeStrength::Strong
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
limiter.record_at(7, Some(1), now + Duration::from_secs(2)),
|
||||||
|
PokeStrength::Suppressed
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
limiter.record_at(7, Some(1), now + Duration::from_secs(5 * 60)),
|
||||||
|
PokeStrength::Strong
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
limiter.record_at(7, Some(1), now + Duration::from_secs(5 * 60 + 1)),
|
||||||
|
PokeStrength::Strong
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "chanora_resolver"
|
name = "chanora_resolver"
|
||||||
version = "0.1.0"
|
version.workspace = true
|
||||||
edition = "2021"
|
edition.workspace = true
|
||||||
license = "MIT OR Apache-2.0"
|
license.workspace = true
|
||||||
publish = false
|
publish.workspace = true
|
||||||
build = "build.rs"
|
build = "build.rs"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
|||||||
@@ -346,9 +346,7 @@ pub fn reduce(state: &mut Option<ServerState>, event: StateEvent) -> Reduction {
|
|||||||
if removed_channel {
|
if removed_channel {
|
||||||
deltas.push(Delta::ChannelRemoved(id));
|
deltas.push(Delta::ChannelRemoved(id));
|
||||||
}
|
}
|
||||||
Reduction {
|
Reduction { deltas }
|
||||||
deltas,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
_ => Reduction { deltas: vec![] },
|
_ => Reduction { deltas: vec![] },
|
||||||
},
|
},
|
||||||
@@ -412,14 +410,7 @@ pub fn reduce_reconnect_snapshot(
|
|||||||
state: &mut Option<ServerState>,
|
state: &mut Option<ServerState>,
|
||||||
snap: ServerSnapshot,
|
snap: ServerSnapshot,
|
||||||
) -> Reduction {
|
) -> Reduction {
|
||||||
let normalized = normalize_snapshot(snap);
|
reduce(state, StateEvent::Snapshot(snap))
|
||||||
*state = Some(ServerState::from_snapshot(normalized.clone()));
|
|
||||||
Reduction {
|
|
||||||
deltas: vec![
|
|
||||||
Delta::ConnectionStateChanged(ConnectionState::Ready),
|
|
||||||
Delta::SnapshotApplied(normalized),
|
|
||||||
],
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
@@ -624,6 +615,7 @@ mod tests {
|
|||||||
sender_name: "Alice".into(),
|
sender_name: "Alice".into(),
|
||||||
message: "hello".into(),
|
message: "hello".into(),
|
||||||
target: MessageTarget::Channel,
|
target: MessageTarget::Channel,
|
||||||
|
poke_strength: None,
|
||||||
};
|
};
|
||||||
let disconnected = reduce(&mut state, StateEvent::ChatReceived(msg.clone()));
|
let disconnected = reduce(&mut state, StateEvent::ChatReceived(msg.clone()));
|
||||||
assert!(disconnected.deltas.is_empty());
|
assert!(disconnected.deltas.is_empty());
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ Chanora is a Flutter application with a Rust core. Flutter owns the user-facing
|
|||||||
| Flutter service layer | `apps/chanora_flutter/lib/services/` | Permission flows, lifecycle policy, host prefetch debounce, link trust, state mapping, platform back intent | Flutter app shell, generated bridge APIs, platform plugins |
|
| Flutter service layer | `apps/chanora_flutter/lib/services/` | Permission flows, lifecycle policy, host prefetch debounce, link trust, state mapping, platform back intent | Flutter app shell, generated bridge APIs, platform plugins |
|
||||||
| Flutter widget layer | `apps/chanora_flutter/lib/widgets/` | Connect UI, channel tree, chat, voice controls, settings, diagnostics surfaces | Flutter services, generated DTOs, design tokens |
|
| Flutter widget layer | `apps/chanora_flutter/lib/widgets/` | Connect UI, channel tree, chat, voice controls, settings, diagnostics surfaces | Flutter services, generated DTOs, design tokens |
|
||||||
| Bridge layer | `crates/chanora_bridge`, `apps/chanora_flutter/lib/src/rust/` | Typed Flutter/Rust boundary and generated bindings | Rust core, Flutter generated code |
|
| Bridge layer | `crates/chanora_bridge`, `apps/chanora_flutter/lib/src/rust/` | Typed Flutter/Rust boundary and generated bindings | Rust core, Flutter generated code |
|
||||||
| Rust core | `core/chanora_core` | Connection lifecycle, orchestration, reconnect behavior, storage coordination, voice state | Protocol, audio, storage, diagnostics, state, resolver/prefetch |
|
| Rust core | `core/chanora_core` | Connection lifecycle, orchestration, reconnect behavior, storage coordination, voice state, bridge-facing event DTOs | Protocol, audio, storage, diagnostics, state, resolver/prefetch |
|
||||||
| Protocol adapter | `crates/chanora_protocol` | Isolate `tsclientlib`, expose typed protocol DTOs/errors | Rust core, external compatible server |
|
| Protocol adapter | `crates/chanora_protocol` | Isolate `tsclientlib`, expose typed protocol DTOs/errors | Rust core, external compatible server |
|
||||||
| State sync | `crates/chanora_state` | Snapshot/delta model, channel join helpers, reducer behavior | Rust core, protocol DTOs |
|
| State sync | `crates/chanora_state` | Snapshot/delta model, channel join helpers, reducer behavior | Rust core, protocol DTOs |
|
||||||
| Audio subsystem | `crates/chanora_audio` | Capture/playback, Opus, DSP, PTT, voice activity reservation, platform units | Rust core, platform APIs, protocol audio path |
|
| Audio subsystem | `crates/chanora_audio` | Capture/playback, Opus, DSP, PTT, voice activity reservation, platform units | Rust core, platform APIs, protocol audio path |
|
||||||
@@ -68,6 +68,8 @@ Flutter UI/widgets/services
|
|||||||
|
|
||||||
The bridge is the trust and type boundary between Flutter and Rust. Flutter must not directly depend on protocol-library internals. Rust core must not expose platform-specific storage or audio details to UI code except through stable DTOs and capability fields.
|
The bridge is the trust and type boundary between Flutter and Rust. Flutter must not directly depend on protocol-library internals. Rust core must not expose platform-specific storage or audio details to UI code except through stable DTOs and capability fields.
|
||||||
|
|
||||||
|
Current Core locality note: the public Core Interface remains available through `chanora_core::*` re-exports, while branch `simplify-project-review` has started moving internal Core responsibilities into focused Modules (`events.rs`, `network_diagnostics.rs`). This is an internal maintainability split, not a public Interface change.
|
||||||
|
|
||||||
## 6. Runtime Flow Architecture
|
## 6. Runtime Flow Architecture
|
||||||
|
|
||||||
### 6.1 Connect Flow
|
### 6.1 Connect Flow
|
||||||
@@ -180,6 +182,8 @@ This SAD derives only from `docs/srs.md`. The broad SRS group-to-component alloc
|
|||||||
| SAD item numbering from historical status references is not reconstructed in this baseline | Existing references such as `SAD-043` and `SAD-046` are not itemized here | Treat this as a DV baseline SAD; add itemized SAD IDs in a follow-up if process requires strict ID-level review |
|
| SAD item numbering from historical status references is not reconstructed in this baseline | Existing references such as `SAD-043` and `SAD-046` are not itemized here | Treat this as a DV baseline SAD; add itemized SAD IDs in a follow-up if process requires strict ID-level review |
|
||||||
| Some architecture views are textual rather than C4 diagrams | Reviewers may request visual C4 views | Record as documentation hardening, not a blocker for DV baseline if textual views are accepted |
|
| Some architecture views are textual rather than C4 diagrams | Reviewers may request visual C4 views | Record as documentation hardening, not a blocker for DV baseline if textual views are accepted |
|
||||||
| Release/platform architecture evidence is incomplete | Public release remains blocked | Controlled by release-readiness and waiver records |
|
| Release/platform architecture evidence is incomplete | Public release remains blocked | Controlled by release-readiness and waiver records |
|
||||||
|
| Android runtime verification is not automatic in local reviews | Android permission/audio/lifecycle regressions can pass Rust-only tests | Require `adb devices -l` with a connected device/emulator and Android smoke evidence before claiming Android runtime success |
|
||||||
|
| Protocol voice packet re-export is an intentional exception to full protocol isolation | Future changes may accidentally widen the protocol/audio Seam | Document and keep the voice wire exception narrow, or move packet construction fully into `chanora_protocol` |
|
||||||
|
|
||||||
## 14. DV Conclusion
|
## 14. DV Conclusion
|
||||||
|
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ This Software Detailed Design defines the module-level design details needed for
|
|||||||
| SDD-MOD-005 Voice UI | `voice_bar.dart`, `voice_compact.dart`, `voice_settings*.dart`, `voice_level_meter.dart`, `ptt_capability_badge.dart` | Voice controls, processing settings, metering, PTT capability | Flutter widget layer |
|
| SDD-MOD-005 Voice UI | `voice_bar.dart`, `voice_compact.dart`, `voice_settings*.dart`, `voice_level_meter.dart`, `ptt_capability_badge.dart` | Voice controls, processing settings, metering, PTT capability | Flutter widget layer |
|
||||||
| SDD-MOD-006 Platform services | `android_permissions_service.dart`, `ios_permissions_service.dart`, `audio_lifecycle_service.dart`, `back_intent_*`, `link_trust_service.dart` | Permission, lifecycle, navigation, route/link trust behavior | Flutter service layer |
|
| SDD-MOD-006 Platform services | `android_permissions_service.dart`, `ios_permissions_service.dart`, `audio_lifecycle_service.dart`, `back_intent_*`, `link_trust_service.dart` | Permission, lifecycle, navigation, route/link trust behavior | Flutter service layer |
|
||||||
| SDD-MOD-007 Bridge API | `crates/chanora_bridge/src/api.rs`, generated Dart/Rust bridge files | Typed command/event boundary | Bridge layer |
|
| SDD-MOD-007 Bridge API | `crates/chanora_bridge/src/api.rs`, generated Dart/Rust bridge files | Typed command/event boundary | Bridge layer |
|
||||||
| SDD-MOD-008 Rust core supervisor | `core/chanora_core/src/lib.rs`, `ptt.rs` | Connection orchestration, reconnect, PTT state, storage coordination | Rust core |
|
| SDD-MOD-008 Rust core supervisor | `core/chanora_core/src/lib.rs`, `events.rs`, `network_diagnostics.rs`, `ptt.rs` | Connection orchestration, reconnect, bridge-facing event DTOs, network diagnostics, PTT state, storage coordination | Rust core |
|
||||||
| SDD-MOD-009 Protocol adapter | `crates/chanora_protocol/src/` | `tsclientlib` isolation, DTO/error mapping | Protocol adapter |
|
| SDD-MOD-009 Protocol adapter | `crates/chanora_protocol/src/` | `tsclientlib` isolation, DTO/error mapping | Protocol adapter |
|
||||||
| SDD-MOD-010 State sync | `crates/chanora_state/src/lib.rs`, `channel_join.rs` | Snapshot/delta model, reducer, channel join support | State sync |
|
| SDD-MOD-010 State sync | `crates/chanora_state/src/lib.rs`, `channel_join.rs` | Snapshot/delta model, reducer, channel join support | State sync |
|
||||||
| SDD-MOD-011 Audio subsystem | `crates/chanora_audio/src/` | Audio capture/playback, DSP, Opus, PTT, mode stack, platform units | Audio subsystem |
|
| SDD-MOD-011 Audio subsystem | `crates/chanora_audio/src/` | Audio capture/playback, DSP, Opus, PTT, mode stack, platform units | Audio subsystem |
|
||||||
@@ -50,6 +50,7 @@ Design rules:
|
|||||||
|---|---|
|
|---|---|
|
||||||
| Connection lifecycle | Rust core owns connect/disconnect/reconnect decisions and suppresses reconnect after user disconnect |
|
| Connection lifecycle | Rust core owns connect/disconnect/reconnect decisions and suppresses reconnect after user disconnect |
|
||||||
| Backoff | Reconnect uses exponential backoff as described in implementation status, capped at 60 seconds |
|
| Backoff | Reconnect uses exponential backoff as described in implementation status, capped at 60 seconds |
|
||||||
|
| Core internal Modules | `lib.rs` remains the public Interface and orchestration entry point; `events.rs` owns public event/bridge-facing DTOs re-exported by `lib.rs`; `network_diagnostics.rs` owns private connect/loss counters and the last-loss ring buffer |
|
||||||
| Server resolution | Resolver performs SRV/TSDNS/DNS fallback; prefetch cache may warm but must not be required for connect success |
|
| Server resolution | Resolver performs SRV/TSDNS/DNS fallback; prefetch cache may warm but must not be required for connect success |
|
||||||
| Snapshot mapping | Rust state and bridge DTOs are mapped into Flutter view models by `snapshot_state_mapper.dart` |
|
| Snapshot mapping | Rust state and bridge DTOs are mapped into Flutter view models by `snapshot_state_mapper.dart` |
|
||||||
| Channel join | Channel join logic and errors are represented through Rust state/protocol handling and Flutter error mapper service |
|
| Channel join | Channel join logic and errors are represented through Rust state/protocol handling and Flutter error mapper service |
|
||||||
@@ -153,6 +154,7 @@ Design rules:
|
|||||||
| Some module designs are summarized rather than API-by-API | May be insufficient for final process audit | Use this as DV baseline; deepen high-risk modules before final release gate |
|
| Some module designs are summarized rather than API-by-API | May be insufficient for final process audit | Use this as DV baseline; deepen high-risk modules before final release gate |
|
||||||
| Android Keystore-backed DEK is not implemented | Limits storage/security design claims | Controlled by waiver and release-readiness records |
|
| Android Keystore-backed DEK is not implemented | Limits storage/security design claims | Controlled by waiver and release-readiness records |
|
||||||
| Full event replay tooling and live reducer integration evidence are absent | Limits state verification design beyond reducer unit behavior | Controlled as P1 gap and runtime-integration follow-up |
|
| Full event replay tooling and live reducer integration evidence are absent | Limits state verification design beyond reducer unit behavior | Controlled as P1 gap and runtime-integration follow-up |
|
||||||
|
| Android runtime smoke is blocked when no device/emulator is attached | Android permission/audio/lifecycle paths cannot be claimed from Rust tests alone | Require `adb devices -l` and Android smoke evidence before closing Android verification claims |
|
||||||
|
|
||||||
## 13. DV Conclusion
|
## 13. DV Conclusion
|
||||||
|
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ This index lists the documents required for DV review and identifies their curre
|
|||||||
| Release | `docs/release/release-readiness-go-nogo-record.md` | No-Go for public/store release |
|
| Release | `docs/release/release-readiness-go-nogo-record.md` | No-Go for public/store release |
|
||||||
| Release | `docs/release/platform-release-policy.md` | Baseline candidate |
|
| Release | `docs/release/platform-release-policy.md` | Baseline candidate |
|
||||||
| Governance | `docs/governance/traceability-matrix.md` | DV baseline candidate |
|
| Governance | `docs/governance/traceability-matrix.md` | DV baseline candidate |
|
||||||
|
| Governance | `docs/governance/maintainability-review-2026-06-08.md` | Working-branch maintainability and fail-safe review |
|
||||||
| Security/privacy/legal | `docs/security/security-privacy-legal-guideline.md` | Baseline candidate |
|
| Security/privacy/legal | `docs/security/security-privacy-legal-guideline.md` | Baseline candidate |
|
||||||
| Privacy | `docs/privacy/privacy-policy.md` | Engineering baseline candidate |
|
| Privacy | `docs/privacy/privacy-policy.md` | Engineering baseline candidate |
|
||||||
| Legal | `docs/legal/trademark-and-attribution-review.md` | DEC-012 open |
|
| Legal | `docs/legal/trademark-and-attribution-review.md` | DEC-012 open |
|
||||||
|
|||||||
@@ -0,0 +1,99 @@
|
|||||||
|
# Chanora Maintainability Review — 2026-06-08
|
||||||
|
|
||||||
|
**Document status:** Working-branch review record
|
||||||
|
**Branch:** `simplify-project-review`
|
||||||
|
**Scope:** Project-wide simplification, fail-safe, and verification review
|
||||||
|
|
||||||
|
## 1. Purpose
|
||||||
|
|
||||||
|
This record captures the current maintainability review so implementation, verification, and release documents do not drift behind the code. It focuses on unnecessary Modules, shallow Interfaces, duplicate Implementations, built-in replacement opportunities, and fail-safe gaps that need explicit evidence before release claims.
|
||||||
|
|
||||||
|
## 2. Changes Already Applied on the Branch
|
||||||
|
|
||||||
|
| Area | Files | Maintainability result |
|
||||||
|
|---|---|---|
|
||||||
|
| Core event DTO locality | `core/chanora_core/src/events.rs`, `core/chanora_core/src/lib.rs` | Public Core event DTOs moved out of the oversized Core integration Module while preserving the public `chanora_core::*` Interface through re-exports. |
|
||||||
|
| Core network diagnostics locality | `core/chanora_core/src/network_diagnostics.rs`, `core/chanora_core/src/lib.rs` | Private network diagnostic ring-buffer state and its regression test now live next to the Implementation they protect. |
|
||||||
|
| Bounded queues | `core/chanora_core/src/network_diagnostics.rs`, `crates/chanora_diagnostics/src/lib.rs` | Replaced `Vec + remove(0)` queue behaviour with `VecDeque`, reducing custom queue code and avoiding O(n) front removal. |
|
||||||
|
| PTT backend errors | `crates/chanora_audio/src/ptt_backends/mod.rs` | Replaced manual `Display` / `Error` Implementation with existing `thiserror::Error`; regression test keeps user-facing strings stable. |
|
||||||
|
| Render downmix | `crates/chanora_audio/src/voice_render.rs`, `crates/chanora_audio/src/ios_raw_unit.rs` | Removed duplicate mono-i16 downmix loop by using the interleaved helper with one output channel. |
|
||||||
|
| State reducer | `crates/chanora_state/src/lib.rs` | Reused the main snapshot reducer for reconnect snapshots instead of duplicating normalization and delta construction. |
|
||||||
|
| Workspace metadata | `crates/chanora_resolver/Cargo.toml`, `Cargo.lock` | Resolver inherits workspace package metadata, improving release metadata Locality. |
|
||||||
|
| Flutter voice fail-safes | `apps/chanora_flutter/lib/main.dart`, `apps/chanora_flutter/lib/widgets/voice_compact.dart`, `apps/chanora_flutter/lib/services/ios_audio_session_controller.dart` | Commit `d835394` preserves independent mute owners, releases touch PTT on disposal while held, and catches iOS audio-session `MissingPluginException` / activation failures so they do not become unhandled async errors. |
|
||||||
|
| Rust realtime callback hardening | `crates/chanora_audio/src/android_voice_unit.rs`, `crates/chanora_audio/src/ios_raw_unit.rs`, `crates/chanora_audio/src/engine.rs` | Commit `8606eb4` hardens Android/iOS realtime callback paths. The current branch also migrates the Android-only JNI paths to `jni-rs` 0.22 so the supported ARM64 Android debug build compiles. Full lock-free audio-handler/config/debug-recorder redesign remains follow-up work. |
|
||||||
|
|
||||||
|
## 3. Remaining Simplification Opportunities
|
||||||
|
|
||||||
|
| Recommendation | Candidate files | Strength | Notes |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Continue splitting Core internals by responsibility | `core/chanora_core/src/lib.rs` | Strong | Next slices should be reconnect/session, voice projection, storage helpers, and diagnostics export. Keep public re-exports stable. |
|
||||||
|
| Make Bridge depend on Core rather than Audio where possible | `crates/chanora_bridge/Cargo.toml`, `crates/chanora_bridge/src/api.rs`, `core/chanora_core/src/lib.rs` | Worth exploring | The Bridge currently has a direct audio edge. Apply the deletion test before removing it. |
|
||||||
|
| Decide whether prefetch deserves a crate-level Seam | `crates/chanora_prefetch/src/lib.rs`, `crates/chanora_resolver/src/lib.rs`, `core/chanora_core/src/lib.rs` | Worth exploring | Prefetch is a small TTL cache and fire-and-forget resolver Adapter. Merge into resolver if it is resolver policy; merge into Core if it is app orchestration policy. |
|
||||||
|
| Consolidate protocol/core/bridge event catalogues | `crates/chanora_protocol/src/dto.rs`, `core/chanora_core/src/events.rs`, `crates/chanora_bridge/src/api.rs` | Worth exploring | Protocol-owned deltas and Core-owned lifecycle events are currently mirrored through multiple DTO layers. |
|
||||||
|
| Reduce bridge DTO mirror boilerplate | `crates/chanora_bridge/src/api.rs` | Worth exploring | Verify Flutter Rust Bridge support before deleting mirrors. If mirrors remain required, centralize conversion patterns and keep field order aligned with Core DTOs. |
|
||||||
|
| Clarify protocol voice packet exception | `crates/chanora_protocol/src/lib.rs`, `crates/chanora_audio/Cargo.toml` | Worth exploring | The protocol crate documents `tsclientlib` isolation but deliberately re-exports voice packet types for audio. Document this as an explicit voice wire Seam or move packet construction fully into protocol. |
|
||||||
|
| Remove shallow audio helpers only after public API check | `crates/chanora_audio/src/processor/noop.rs`, `crates/chanora_audio/src/processor/platform.rs`, `crates/chanora_audio/src/frame.rs` | Speculative | These Modules are shallow, but deletion must wait until external/public API expectations are checked. |
|
||||||
|
| Redesign remaining audio shared state outside realtime callbacks | `crates/chanora_audio/src/engine.rs`, platform voice units, debug recorder/config paths | Strong follow-up | The focused callback hardening is complete, but a full lock-free `AudioHandler` / config / debug-recorder redesign should be planned separately and verified on device. |
|
||||||
|
| Review protocol/core disconnect and control-plane bounds | `core/chanora_core/src/lib.rs`, `crates/chanora_protocol/src/adapter.rs` | Strong follow-up | Unless closed by a later code slice, sustained voice traffic and broken transport should be reviewed for bounded control request and disconnect progress. |
|
||||||
|
|
||||||
|
## 4. Fail-Safe Gaps That Need Evidence
|
||||||
|
|
||||||
|
| Gap | Risk | Required evidence before release claim |
|
||||||
|
|---|---|---|
|
||||||
|
| Android Keystore-backed DEK remains deferred | Android identity/bookmark encryption has weaker fail-safe properties than final target secure-storage design. | Android secure-storage audit or waiver; explicit release-readiness limitation. |
|
||||||
|
| Android permission/audio lifecycle needs deeper route exercise | Build/install/launch smoke now passes on the emulator, but full permission-flow and audio-route lifecycle behavior still need an interactive scenario or device test before release. | Device/emulator scenario covering permission request/denial/grant, voice controls, foreground service, audio focus, and route/SCO transitions. |
|
||||||
|
| iOS device runtime verification not executed in this review | The iOS audio-session error path is hardened, but VoiceProcessingIO/session ordering and runtime audio behavior still need device evidence. | iOS device or simulator build/run plus audio-session smoke evidence before iOS runtime success is claimed. |
|
||||||
|
| VAD / VoiceActivity wording drift | VAD assets, tests, and scaffolding exist, but product-enabled `VoiceActivity` remains reserved/disabled per DEC-030. | Release, README, and verification wording must distinguish scaffolding/assets/tests from shipped product behavior. |
|
||||||
|
| Protocol voice packet re-export is an intentional exception | Future maintainers may assume complete protocol isolation and accidentally widen the Seam. | Architecture note in SAD/SDD or a decision-register entry. |
|
||||||
|
| Bridge DTO mirror drift | Field additions can be missed across Core, Bridge, and Dart generated DTOs. | Bridge generation check plus Flutter analyze/test after bridge DTO changes. |
|
||||||
|
| Full live reducer integration remains separate from reducer unit coverage | State reducer tests are strong, but runtime UI still has snapshot/probe paths. | SWE.5 integration run proving live protocol events fold through the intended state path, or explicit P1 deferral. |
|
||||||
|
| Event replay tooling remains absent | Replay-based diagnosis and regression reproduction are limited. | Event replay tool implementation or waiver. |
|
||||||
|
|
||||||
|
## 5. Verification Policy for Future Code Changes
|
||||||
|
|
||||||
|
| Change type | Required verification |
|
||||||
|
|---|---|
|
||||||
|
| Rust-only change | `cargo fmt --all`, `cargo check --workspace`, `cargo test --workspace` |
|
||||||
|
| Bridge DTO/API change | Rust verification plus bridge generation check, `flutter analyze`, and `flutter test --exclude-tags e2e` in `apps/chanora_flutter` |
|
||||||
|
| Android platform/audio/permission change | Rust/Flutter verification plus NDK target compilation, `adb devices -l`, Android build/install, and a device or emulator smoke test |
|
||||||
|
| Documentation-only change | Read affected docs and ensure cross-links/document index stay current; code tests are not required unless docs describe a code change just made |
|
||||||
|
|
||||||
|
## 6. Android ADB Status for This Review
|
||||||
|
|
||||||
|
`adb devices -l` now reports an authorized emulator target:
|
||||||
|
|
||||||
|
```text
|
||||||
|
emulator-5554 device product:sdk_gphone64_arm64 model:sdk_gphone64_arm64 device:emu64a transport_id:1
|
||||||
|
```
|
||||||
|
|
||||||
|
Android default debug build still fails because SDD-118 excludes `armeabi-v7a`; use a supported ABI target. ARM64 debug build/install/launch smoke evidence from 2026-06-08:
|
||||||
|
|
||||||
|
```text
|
||||||
|
flutter build apk --debug --target-platform android-arm64
|
||||||
|
✓ Built build/app/outputs/flutter-apk/app-debug.apk
|
||||||
|
|
||||||
|
adb -s emulator-5554 install -r apps/chanora_flutter/build/app/outputs/flutter-apk/app-debug.apk
|
||||||
|
Success
|
||||||
|
|
||||||
|
adb -s emulator-5554 shell am start -W -n app.chanora.chanora_flutter/.MainActivity
|
||||||
|
Status: ok
|
||||||
|
LaunchState: COLD
|
||||||
|
Activity: app.chanora.chanora_flutter/.MainActivity
|
||||||
|
TotalTime: 6514
|
||||||
|
|
||||||
|
adb -s emulator-5554 shell pidof app.chanora.chanora_flutter
|
||||||
|
7287
|
||||||
|
```
|
||||||
|
|
||||||
|
`dumpsys window app.chanora.chanora_flutter` showed `MainActivity` visible with `isReadyForDisplay()=true`, and `dumpsys activity top` showed `app.chanora.chanora_flutter/.MainActivity` resumed with window focus. This is build/install/launch smoke evidence only; permission-flow success and audio-lifecycle success are not claimed by this review.
|
||||||
|
|
||||||
|
## 7. Release and Documentation Alignment Notes
|
||||||
|
|
||||||
|
- Android minimum runtime baseline is API 28 (Android 9.0) per SysRS-288, SRS-187, DEC-004, and the Gradle `minSdk = 28` configuration. Documents must not revive the older API 24 baseline.
|
||||||
|
- Flutter app version/build is `0.3.0+100` in `apps/chanora_flutter/pubspec.yaml`. Rust workspace package version remains `0.2.0-beta.1`. Release documents must distinguish these values instead of treating them as one candidate version.
|
||||||
|
- The v0.3.0 changelog entry may mention VAD assets/backends only as implementation scaffolding; product-enabled `VoiceActivity` remains disabled/coming-soon until DEC-030 is superseded and runtime verification exists.
|
||||||
|
- README wording must describe the existing Flutter/Rust workspace and app scaffold, not a future scaffold that has not been created.
|
||||||
|
|
||||||
|
## 8. Git Policy
|
||||||
|
|
||||||
|
No commit is created automatically. Commit only on explicit user demand, after reviewing `git status`, `git diff`, and recent log output.
|
||||||
@@ -11,12 +11,14 @@ This register records product and engineering decisions referenced by the DV doc
|
|||||||
|
|
||||||
| Decision | State | DV impact |
|
| Decision | State | DV impact |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
|
| DEC-004 Android minimum runtime API 28 | Accepted by requirements baseline | Android release, verification, and README wording must use API 28 rather than the earlier API 24 recommendation |
|
||||||
| DEC-012 legal/trademark/OSS review | Open | Blocks public/store release |
|
| DEC-012 legal/trademark/OSS review | Open | Blocks public/store release |
|
||||||
| DEC-020 dual license MIT OR Apache-2.0 | Accepted per README | Supports license posture; dependency notices still require review |
|
| DEC-020 dual license MIT OR Apache-2.0 | Accepted per README | Supports license posture; dependency notices still require review |
|
||||||
| DEC-027 desktop mouse side-button PTT | Accepted by requirements baseline | Verification must not over-claim unsupported platform input classes |
|
| DEC-027 desktop mouse side-button PTT | Accepted by requirements baseline | Verification must not over-claim unsupported platform input classes |
|
||||||
| DEC-030 VAD deferral | Accepted as deferral | `VoiceActivity` remains disabled/coming-soon |
|
| DEC-030 VAD deferral | Accepted as deferral | VAD scaffolding, assets, and tests may exist, but product `VoiceActivity` remains disabled/coming-soon until a later baseline enables and verifies it |
|
||||||
| DEC-032 Android CMake patch exit path | Active tracking | Patched dependency requires reevaluation |
|
| DEC-032 Android CMake patch exit path | Active tracking | Patched dependency requires reevaluation |
|
||||||
| DEC-033 macOS VPIO ducking configuration | Accepted | Write `kAUVoiceIOProperty_OtherAudioDuckingConfiguration` with `mEnableAdvancedDucking=0` (disables dynamic voice-activity-driven ducking) and `mDuckingLevel=Min` (= 10) to minimise the ducking of other apps' audio during a voice session; property is macOS 14+ only, the macOS 13 set fails silently (debug log) and VPIO uses its default behaviour; matches the iOS `.voiceChat` baseline on macOS 14+ |
|
| DEC-033 macOS VPIO ducking configuration | Accepted | Write `kAUVoiceIOProperty_OtherAudioDuckingConfiguration` with `mEnableAdvancedDucking=0` (disables dynamic voice-activity-driven ducking) and `mDuckingLevel=Min` (= 10) to minimise the ducking of other apps' audio during a voice session; property is macOS 14+ only, the macOS 13 set fails silently (debug log) and VPIO uses its default behaviour; matches the iOS `.voiceChat` baseline on macOS 14+ |
|
||||||
|
| DEC-034 Android runtime verification gate | Active tracking | Android target compilation, install, and runtime smoke are blocked locally until `aarch64-linux-android-clang` is available and `adb devices -l` shows an authorized target; release docs must not claim Android runtime success |
|
||||||
|
|
||||||
## 3. DV Rule
|
## 3. DV Rule
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
# Chanora Implementation Status — 2026-05-28
|
# Chanora Implementation Status — 2026-05-28
|
||||||
|
|
||||||
**Workspace version:** `v0.2.0-beta.1`
|
**Workspace version:** `v0.2.0-beta.1`
|
||||||
**CHANGELOG latest:** `v1.0.0-rc.1`
|
**Flutter app version/build:** `0.3.0+100`
|
||||||
**Build status:** All 9 crates compile cleanly.
|
**CHANGELOG latest:** `v0.3.0`
|
||||||
|
**Build status:** Host Rust workspace evidence shows all 9 crates compile cleanly. This does not claim Android target success; Android target compile/install/smoke evidence remains blocked locally as noted below.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -18,9 +19,9 @@
|
|||||||
| Localization (en + zh-Hans) | `l10n/generated/app_localizations_en.dart` + `app_localizations_zh.dart`, `l10n.yaml` |
|
| Localization (en + zh-Hans) | `l10n/generated/app_localizations_en.dart` + `app_localizations_zh.dart`, `l10n.yaml` |
|
||||||
| Flutter/Rust bridge | `chanora_bridge` crate (2152-line `api.rs`), generated `frb_generated.rs`, Dart side generated |
|
| Flutter/Rust bridge | `chanora_bridge` crate (2152-line `api.rs`), generated `frb_generated.rs`, Dart side generated |
|
||||||
| Protocol adapter | `chanora_protocol` — `tsclientlib` isolated behind `ProtocolClient`, typed DTOs, `ProtocolError` catalogue |
|
| Protocol adapter | `chanora_protocol` — `tsclientlib` isolated behind `ProtocolClient`, typed DTOs, `ProtocolError` catalogue |
|
||||||
| Connection lifecycle | `chanora_core` (2682-line `lib.rs`) — supervisor task, exponential backoff reconnect (1s→60s), user-disconnect suppresses reconnect |
|
| Connection lifecycle | `chanora_core` — supervisor task, exponential backoff reconnect (1s→60s), user-disconnect suppresses reconnect; branch `simplify-project-review` has started splitting the previous large `lib.rs` into focused internal Modules (`events.rs`, `network_diagnostics.rs`) while preserving public re-exports |
|
||||||
| State sync reducer unit | `chanora_state` — `ConnectionState`, `channel_join`, snapshot/delta reducers, reconnect handling, deterministic ordering, malformed duplicate normalization, channel-delete/client cleanup, and reducer unit tests. Runtime core integration still uses snapshot/probe refresh paths and remains separate validation work. |
|
| State sync reducer unit | `chanora_state` — `ConnectionState`, `channel_join`, snapshot/delta reducers, reconnect handling, deterministic ordering, malformed duplicate normalization, channel-delete/client cleanup, and reducer unit tests. Runtime core integration still uses snapshot/probe refresh paths and remains separate validation work. |
|
||||||
| Audio subsystem | `chanora_audio` — Opus encode/decode, HPF/NS/AEC3/AGC2 DSP, PTT backends (Windows/macOS/Linux/focused), iOS VoiceProcessingIO, Android Oboe, jitter buffer via `tsclientlib::audio::AudioHandler`, mixer, mute/deaf gates, release-tail timer, VAD |
|
| Audio subsystem | `chanora_audio` — Opus encode/decode, HPF/NS/AEC3/AGC2 DSP, PTT backends (Windows/macOS/Linux/focused), iOS VoiceProcessingIO, Android Oboe, jitter buffer via `tsclientlib::audio::AudioHandler`, mixer, mute/deaf gates, release-tail timer, and VAD scaffolding/assets. Product `VoiceActivity` remains disabled per DEC-030. |
|
||||||
| Push-to-talk | Per-platform backends: Windows Raw Input + hook fallback, macOS Event Tap, Linux freedesktop portal, focused fallback; `PttCapabilityLevel` (L0–L3); missed-key-up watchdog |
|
| Push-to-talk | Per-platform backends: Windows Raw Input + hook fallback, macOS Event Tap, Linux freedesktop portal, focused fallback; `PttCapabilityLevel` (L0–L3); missed-key-up watchdog |
|
||||||
| Voice controls UI | `voice_bar`, `voice_compact`, `voice_haptics`, `voice_level_meter`, `voice_platform`, `ptt_capability_badge`, `talk_power_warning` |
|
| Voice controls UI | `voice_bar`, `voice_compact`, `voice_haptics`, `voice_level_meter`, `voice_platform`, `ptt_capability_badge`, `talk_power_warning` |
|
||||||
| Storage (non-secret) | `chanora_storage` — `BookmarkRepository` (SQLite/rusqlite bundled, schema v2), ChaCha20-Poly1305 encrypted passwords |
|
| Storage (non-secret) | `chanora_storage` — `BookmarkRepository` (SQLite/rusqlite bundled, schema v2), ChaCha20-Poly1305 encrypted passwords |
|
||||||
@@ -45,7 +46,7 @@
|
|||||||
| Link trust | `link_trust_service.dart` |
|
| Link trust | `link_trust_service.dart` |
|
||||||
| About dialog | Non-affiliation statement, dual-license declaration, NOTICE pointer |
|
| About dialog | Non-affiliation statement, dual-license declaration, NOTICE pointer |
|
||||||
| CI | GitHub Actions on every push |
|
| CI | GitHub Actions on every push |
|
||||||
| Workspace compiles | All 9 crates build cleanly |
|
| Workspace compiles | Host Rust workspace evidence shows all 9 crates build cleanly; Android target compilation remains blocked locally as noted below. |
|
||||||
|
|
||||||
### Partial / Scaffold Only
|
### Partial / Scaffold Only
|
||||||
|
|
||||||
@@ -54,17 +55,18 @@
|
|||||||
| Event replay tooling | Reducer tests cover the state-sync contract, but standalone replay-file tooling remains a P1 verification gap. |
|
| Event replay tooling | Reducer tests cover the state-sync contract, but standalone replay-file tooling remains a P1 verification gap. |
|
||||||
| Reducer runtime integration evidence | The standalone reducer is unit-tested, but `chanora_core` still refreshes UI state through snapshot/probe paths rather than folding all live protocol events through `chanora_state::reduce`. |
|
| Reducer runtime integration evidence | The standalone reducer is unit-tested, but `chanora_core` still refreshes UI state through snapshot/probe paths rather than folding all live protocol events through `chanora_state::reduce`. |
|
||||||
| Silero VAD | `assets/models/silero_vad.onnx` bundled but DEC-030 defers VAD to P1; `TransmitMode::VoiceActivity` is reserved and disabled in this baseline. |
|
| Silero VAD | `assets/models/silero_vad.onnx` bundled but DEC-030 defers VAD to P1; `TransmitMode::VoiceActivity` is reserved and disabled in this baseline. |
|
||||||
| macOS build | Not in `v1.0.0-rc.1` release artifacts (source-buildable only per `staged-release-plan.md`). |
|
| macOS build | Source-buildable only; no public release artifact is approved. |
|
||||||
| Windows build | Same — source-buildable, not in rc.1 release artifacts. |
|
| Windows build | Source-buildable only; no public release artifact is approved. |
|
||||||
| iOS build | Same — source-buildable, not in rc.1 release artifacts. |
|
| iOS build | Source-buildable/unsigned validation only; no TestFlight/App Store release artifact is approved. |
|
||||||
|
|
||||||
### Not Done (P0 blockers remaining)
|
### Not Done (P0 blockers remaining)
|
||||||
|
|
||||||
| Item | Status |
|
| Item | Status |
|
||||||
|---|---|
|
|---|---|
|
||||||
| DEC-012 legal/trademark/OSS review | Explicitly open — `v1.0.0-rc.1` is the candidate awaiting sign-off. Public release is blocked. |
|
| DEC-012 legal/trademark/OSS review | Explicitly open. Public release is blocked. |
|
||||||
| Android Keystore-backed DEK | Deferred to v1.1. Android still uses file-fallback for the Data Encryption Key. |
|
| Android Keystore-backed DEK | Deferred to v1.1. Android still uses file-fallback for the Data Encryption Key. |
|
||||||
| iOS `AVAudioSession.Mode.voiceChat` | Implemented in `apps/chanora_flutter/ios/Runner/AppDelegate.swift`; release readiness still requires device audio validation and candidate evidence attachment. |
|
| Android target compile/install/smoke evidence | Blocked locally until the Android NDK compiler `aarch64-linux-android-clang` is available and `adb devices -l` shows an authorized device or emulator. |
|
||||||
|
| iOS `AVAudioSession.Mode.voiceChat` | Implemented in `apps/chanora_flutter/ios/Runner/AppDelegate.swift` with call-scoped activation (idle `.ambient` baseline; VoIP `.playAndRecord` + `.voiceChat` + `.mixWithOthers` engaged only on `BridgeEvent::AudioStarted` via `chanora/ios_audio_session` MethodChannel). Release readiness still requires device audio validation and candidate evidence attachment. |
|
||||||
| Candidate state-sync evidence attachment | Reducer tests exist and pass locally; release readiness still needs candidate CI/run IDs and runtime integration evidence attached before public release approval. |
|
| Candidate state-sync evidence attachment | Reducer tests exist and pass locally; release readiness still needs candidate CI/run IDs and runtime integration evidence attached before public release approval. |
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -92,7 +94,7 @@
|
|||||||
| Recent servers persistence (SRS-085) | Not confirmed in storage crate |
|
| Recent servers persistence (SRS-085) | Not confirmed in storage crate |
|
||||||
| UI settings persistence (SRS-087) | Implemented for current P1 scope using `shared_preferences`: host, nickname, permission explanation flag, and theme mode (`system` / `light` / `dark`). SQLite-backed UI settings remain a future hardening option if multi-profile or transactional settings are introduced. |
|
| UI settings persistence (SRS-087) | Implemented for current P1 scope using `shared_preferences`: host, nickname, permission explanation flag, and theme mode (`system` / `light` / `dark`). SQLite-backed UI settings remain a future hardening option if multi-profile or transactional settings are introduced. |
|
||||||
| Event replay tool (SRS-061, SRS-098) | No replay infrastructure found |
|
| Event replay tool (SRS-061, SRS-098) | No replay infrastructure found |
|
||||||
| Network diagnostics (SRS-100) | Not found in diagnostics export |
|
| Network diagnostics (SRS-100) | Core tracks connect/disconnect counts and last-loss reasons in `network_diagnostics.rs`; export/integration evidence still needs release-candidate attachment |
|
||||||
| Side navigation rail for medium layout (SRS-153) | Not confirmed |
|
| Side navigation rail for medium layout (SRS-153) | Not confirmed |
|
||||||
| Keyboard focus traversal (SRS-160) | Not confirmed |
|
| Keyboard focus traversal (SRS-160) | Not confirmed |
|
||||||
| Android audio focus / BT route changes (SRS-112) | Partial — `MODE_IN_COMMUNICATION` done; full focus/BT handling not confirmed |
|
| Android audio focus / BT route changes (SRS-112) | Partial — `MODE_IN_COMMUNICATION` done; full focus/BT handling not confirmed |
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ A waiver records a known gap that reviewers may accept for a limited decision sc
|
|||||||
| DV-WVR-006 | Desktop and iOS artifacts are source-buildable or unsigned only | `docs/implementation-status-2026-05-28.md`, `docs/release/ios-build.md` | Blocks packaged public release claims | Internal validation from source/unsigned builds only | Signed/notarized/package artifacts exist and hashes are recorded |
|
| DV-WVR-006 | Desktop and iOS artifacts are source-buildable or unsigned only | `docs/implementation-status-2026-05-28.md`, `docs/release/ios-build.md` | Blocks packaged public release claims | Internal validation from source/unsigned builds only | Signed/notarized/package artifacts exist and hashes are recorded |
|
||||||
| DV-WVR-007 | Silero VAD asset bundled while `VoiceActivity` is deferred | `docs/implementation-status-2026-05-28.md` | Risk that UI/release wording overstates VAD availability | DV may pass if VoiceActivity remains disabled/coming-soon | VAD implementation allocated in a later baseline or asset/wording reconciled |
|
| DV-WVR-007 | Silero VAD asset bundled while `VoiceActivity` is deferred | `docs/implementation-status-2026-05-28.md` | Risk that UI/release wording overstates VAD availability | DV may pass if VoiceActivity remains disabled/coming-soon | VAD implementation allocated in a later baseline or asset/wording reconciled |
|
||||||
| DV-WVR-008 | Artifact hashes, tag, and candidate run IDs are not recorded in release record | `docs/release/release-readiness-go-nogo-record.md` | Blocks final release approval and reproducibility | DV documentation review only | Candidate build run records, tag, commit SHA, and artifact hashes are recorded |
|
| DV-WVR-008 | Artifact hashes, tag, and candidate run IDs are not recorded in release record | `docs/release/release-readiness-go-nogo-record.md` | Blocks final release approval and reproducibility | DV documentation review only | Candidate build run records, tag, commit SHA, and artifact hashes are recorded |
|
||||||
|
| DV-WVR-009 | Android target compile and runtime smoke blocked locally | `docs/governance/maintainability-review-2026-06-08.md`, `docs/release/release-readiness-go-nogo-record.md` | Blocks Android runtime, permission-flow, and audio-lifecycle success claims | Documentation review only; internal validation must keep Android limitation stated | Android NDK compiler `aarch64-linux-android-clang` is available, `adb devices -l` shows an authorized target, and Android build/install/smoke evidence is attached |
|
||||||
|
|
||||||
## 3. Waiver Review Rules
|
## 3. Waiver Review Rules
|
||||||
|
|
||||||
|
|||||||
@@ -1,148 +0,0 @@
|
|||||||
# iOS / macOS Export Compliance
|
|
||||||
|
|
||||||
Status: TestFlight workaround applied. Production submission requires the steps below.
|
|
||||||
|
|
||||||
## Current state (2026-06-08)
|
|
||||||
|
|
||||||
Both `apps/chanora_flutter/ios/Runner/Info.plist` and `apps/chanora_flutter/macos/Runner/Info.plist` declare:
|
|
||||||
|
|
||||||
```xml
|
|
||||||
<key>ITSAppUsesNonExemptEncryption</key>
|
|
||||||
<false/>
|
|
||||||
```
|
|
||||||
|
|
||||||
This is a **temporary workaround** to unblock TestFlight uploads. It is NOT correct for public App Store release because Chanora ships non-exempt encryption (see "Why Chanora is not exempt" below). The workaround is acceptable for internal testing under TestFlight while the BIS self-classification and Apple export-compliance documentation are being prepared.
|
|
||||||
|
|
||||||
## Why Chanora is not exempt
|
|
||||||
|
|
||||||
Chanora is a **closed-source proprietary** application that uses encryption beyond Apple's "standard system encryption" exemption:
|
|
||||||
|
|
||||||
1. `chacha20poly1305` in `crates/chanora_storage` — AEAD encryption of the local Data Encryption Key (DEK) for secret storage.
|
|
||||||
2. `tsclientlib` in `crates/chanora_protocol` and `crates/chanora_audio` — implements TeamSpeak's custom Ed25519 / P-256 / EAX cryptographic handshake. This is NOT standard IETF TLS.
|
|
||||||
3. `rustls` in `crates/chanora_protocol` and `crates/chanora_resolver` — ships its own TLS implementation rather than using the system stack.
|
|
||||||
|
|
||||||
Apple's exemption (HTTPS via system networking only) does not apply. Because Chanora is closed-source, the "publicly available source code" path (§734.3(b)(3) + §742.15(b)) is also unavailable — that route is reserved for open-source distributions whose cryptographic source is publicly accessible without restriction.
|
|
||||||
|
|
||||||
Chanora's path is therefore the **mass-market self-classification** route under §740.17(b)(1) with Note 3 to Category 5, Part 2 of the CCL — closed-source consumer apps with standard published algorithms qualify as mass-market and self-classify under **ECCN 5D992.c**. `ITSAppUsesNonExemptEncryption` must be `<true/>` for any public release, and `ITSEncryptionExportComplianceCode` must accompany it.
|
|
||||||
|
|
||||||
## The submission flow (in order)
|
|
||||||
|
|
||||||
### Step 1: File the BIS annual self-classification report
|
|
||||||
|
|
||||||
US Department of Commerce, Bureau of Industry and Security (BIS), requires an annual self-classification report for mass-market encryption software under EAR §740.17(b)(1). Since Chanora is **closed-source** but distributed as a mass-market consumer app with standard cryptographic algorithms, it qualifies for self-classification under the **mass-market note (Note 3 to Category 5, Part 2 of the CCL)**, which reclassifies it from ECCN 5D002.c.1 to **ECCN 5D992.c**.
|
|
||||||
|
|
||||||
There is **no fee**, **no review**, **no pre-approval needed**. You export immediately under (b)(1) and file the report within 30 days.
|
|
||||||
|
|
||||||
The format is a **CSV file** per Supplement No. 8 to Part 742, not a prose email body. Send it as an attachment:
|
|
||||||
|
|
||||||
**Recipients** (CC both):
|
|
||||||
- `crypt@bis.doc.gov`
|
|
||||||
- `enc@nsa.gov`
|
|
||||||
|
|
||||||
**Subject**: `Self-Classification Report — Chanora — [YEAR]`
|
|
||||||
|
|
||||||
**CSV template** (fill in `[BRACKETS]`):
|
|
||||||
|
|
||||||
```csv
|
|
||||||
Submitter Name,Submitter Email,Submitter Phone,Submitter Address,Item Type,Item Name,Item Version,ECCN,Authorization Type,Encryption Algorithm,Key Length,Item Description
|
|
||||||
[YOUR NAME],[YOU@EMAIL.COM],[PHONE],[ADDRESS],Mass Market Encryption Software,Chanora,[VERSION e.g. v1.0.0-rc.1],5D992.c,MMKT,ChaCha20-Poly1305; Ed25519; P-256 ECDH; EAX-AES; TLS 1.2/1.3,256,Cross-platform voice communication client for TeamSpeak-compatible servers. Cryptographic algorithms are all published standards; the application is closed-source proprietary software distributed via mass-market channels (Apple App Store).
|
|
||||||
```
|
|
||||||
|
|
||||||
**Notes on the CSV**:
|
|
||||||
- **Authorization Type** `MMKT` signals the mass-market self-classification under Note 3.
|
|
||||||
- **ECCN** `5D992.c` — the mass-market note reclassifies closed-source mass-market software with standard crypto from 5D002.c.1 to 5D992.c, removing it from "EI" and "NS" controls.
|
|
||||||
- The CSV is a **single row** for one product. For multiple products, add one row per product.
|
|
||||||
- Send it **once per calendar year** (BIS deadline: February 1 for prior calendar year).
|
|
||||||
- Keep the sent-mail receipt — Apple may ask to see proof during the App Store review.
|
|
||||||
- BIS does not reply. Filing the report is the compliance event; no approval or confirmation is needed.
|
|
||||||
|
|
||||||
### Step 2: App Store Connect — encryption questionnaire
|
|
||||||
|
|
||||||
Path: `App Store Connect → My Apps → Chanora → App Information → Encryption` (or during version submission, the same questionnaire appears).
|
|
||||||
|
|
||||||
Answer the questions as follows for Chanora:
|
|
||||||
|
|
||||||
1. "Does your app use encryption?" → **Yes**
|
|
||||||
2. "Does your app qualify for any of the exemptions provided in Category 5, Part 2 of the U.S. Export Administration Regulations?" → **No**
|
|
||||||
(We do use non-exempt crypto — see "Why Chanora is not exempt".)
|
|
||||||
3. "Does your app implement any encryption algorithms that are proprietary or not accepted as standard by international standard bodies (IEEE, IETF, ITU, etc.)?" → **No**
|
|
||||||
(ChaCha20-Poly1305 is RFC 8439; Ed25519 is RFC 8032; P-256 is FIPS 186-4. TeamSpeak's handshake uses standard primitives even though the orchestration is custom.)
|
|
||||||
4. "Does your app implement any standard encryption algorithms instead of, or in addition to, using or accessing the encryption in Apple's operating system?" → **Yes**
|
|
||||||
5. "Is your app made available on the French App Store?" → answer per your distribution plan (Yes if you want EU/FR availability).
|
|
||||||
|
|
||||||
Apple will then ask you to upload supporting documentation. Upload:
|
|
||||||
- A PDF copy of your sent email from Step 1 (showing the date sent and both recipients), with the CSV attachment shown or attached.
|
|
||||||
- A short cover sheet identifying: product name (Chanora), ECCN (`5D992.c`), authorization basis (`§740.17(b)(1)` mass-market self-classification under Note 3 to Category 5, Part 2 of the CCL), and the date the report was sent to BIS.
|
|
||||||
|
|
||||||
### Step 3: Receive the code from Apple
|
|
||||||
|
|
||||||
After review (typical turnaround: a few business days to ~2 weeks), Apple sends an email with the export compliance code. The code is a UUID, lowercase, 36 characters, e.g. `aeafaa12-a017-48c8-a29a-bdd3061f93e3` (this is Keybase's actual code, used here as a format example only).
|
|
||||||
|
|
||||||
The code is **per-app, not per-version**. Once issued it can be reused for every subsequent build until your encryption usage materially changes.
|
|
||||||
|
|
||||||
### Step 4: Revert the workaround and patch in the code
|
|
||||||
|
|
||||||
Edit `apps/chanora_flutter/ios/Runner/Info.plist`:
|
|
||||||
|
|
||||||
```xml
|
|
||||||
<key>ITSAppUsesNonExemptEncryption</key>
|
|
||||||
<true/>
|
|
||||||
<key>ITSEncryptionExportComplianceCode</key>
|
|
||||||
<string>PASTE_APPLE_ISSUED_UUID_HERE</string>
|
|
||||||
```
|
|
||||||
|
|
||||||
Do the same for `apps/chanora_flutter/macos/Runner/Info.plist`.
|
|
||||||
|
|
||||||
Optionally — to avoid committing the literal UUID to git — use a build variable instead (matches the `procivis/one-wallet` pattern):
|
|
||||||
|
|
||||||
```xml
|
|
||||||
<key>ITSAppUsesNonExemptEncryption</key>
|
|
||||||
<true/>
|
|
||||||
<key>ITSEncryptionExportComplianceCode</key>
|
|
||||||
<string>$(CHANORA_IOS_EXPORT_COMPLIANCE_CODE)</string>
|
|
||||||
```
|
|
||||||
|
|
||||||
Then set `CHANORA_IOS_EXPORT_COMPLIANCE_CODE` as a build setting (in `Runner.xcodeproj` user-defined settings, in a non-tracked `.xcconfig`, or via the CI/Fastlane environment).
|
|
||||||
|
|
||||||
Validate after editing:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
plutil -lint apps/chanora_flutter/ios/Runner/Info.plist
|
|
||||||
plutil -lint apps/chanora_flutter/macos/Runner/Info.plist
|
|
||||||
```
|
|
||||||
|
|
||||||
### Step 5: Submit and confirm
|
|
||||||
|
|
||||||
Re-upload the build via Xcode → Organizer → Distribute App, or via Fastlane / CI. App Store Connect should no longer reject the build with the "Invalid Export Compliance Code" error.
|
|
||||||
|
|
||||||
If you use Fastlane, you can also pass `uses_non_exempt_encryption: true` to `upload_to_testflight` / `upload_to_app_store` as belt-and-suspenders; the Info.plist value is authoritative.
|
|
||||||
|
|
||||||
## Annual maintenance
|
|
||||||
|
|
||||||
- Every January, re-send the BIS self-classification CSV (Step 1) with the current year and the latest released version. Deadline: **February 1** for the prior calendar year. Keep the receipt.
|
|
||||||
- The Apple-issued code does NOT need re-issuance unless Chanora adds a fundamentally new encryption capability (e.g. end-to-end encrypted DMs would qualify; bumping an existing AEAD library version would not).
|
|
||||||
- If you add E2EE for direct messages, change the at-rest encryption algorithm, or introduce proprietary (non-standard) cryptography, the mass-market classification may no longer apply — you would need to either re-self-classify or file a CCATS (Commodity Classification) request via SNAP-R, and Apple will need updated documentation and a new code.
|
|
||||||
|
|
||||||
## References
|
|
||||||
|
|
||||||
- Apple — `ITSAppUsesNonExemptEncryption` key: <https://developer.apple.com/documentation/bundleresources/information-property-list/itsappusesnonexemptencryption>
|
|
||||||
- Apple — `ITSEncryptionExportComplianceCode` key: <https://developer.apple.com/documentation/bundleresources/information-property-list/itsencryptionexportcompliancecode>
|
|
||||||
- Apple — Complying with encryption export regulations: <https://developer.apple.com/documentation/security/complying-with-encryption-export-regulations>
|
|
||||||
- Apple — App Store Connect Help, export compliance overview: <https://developer.apple.com/help/app-store-connect/manage-app-information/overview-of-export-compliance/>
|
|
||||||
- Apple — App Store Connect Help, determine and upload encryption documentation: <https://developer.apple.com/help/app-store-connect/manage-app-information/determine-and-upload-app-encryption-documentation/>
|
|
||||||
- BIS — Encryption FAQs: <https://www.bis.doc.gov/index.php/policy-guidance/encryption>
|
|
||||||
- BIS — EAR §740.17 (License Exception ENC, self-classification): <https://www.ecfr.gov/current/title-15/subtitle-B/chapter-VII/subchapter-C/part-740/section-740.17>
|
|
||||||
- BIS — Supplement No. 8 to Part 742 (self-classification report CSV format): <https://www.ecfr.gov/current/title-15/subtitle-B/chapter-VII/subchapter-C/part-742>
|
|
||||||
- BIS — Note 3 to Category 5, Part 2 of the CCL (mass-market note): <https://www.ecfr.gov/current/title-15/subtitle-B/chapter-VII/subchapter-C/part-774>
|
|
||||||
- Real-world examples (closed-source iOS apps with `ITSEncryptionExportComplianceCode`):
|
|
||||||
- Keybase: <https://github.com/keybase/client/blob/master/shared/ios/Keybase/Info.plist>
|
|
||||||
- Status.im: <https://github.com/status-im/status-mobile/blob/develop/ios/StatusIm/Info.plist>
|
|
||||||
- Cryptomator: <https://github.com/cryptomator/ios/blob/develop/Cryptomator/Info.plist>
|
|
||||||
- Build-variable pattern (avoid committing UUID to git):
|
|
||||||
- Procivis One Wallet: <https://github.com/procivis/one-wallet/blob/main/ios/Wallet/Info.plist>
|
|
||||||
|
|
||||||
## Decision log
|
|
||||||
|
|
||||||
| Date | Decision | Rationale |
|
|
||||||
|---|---|---|
|
|
||||||
| 2026-06-08 | Temporarily set `ITSAppUsesNonExemptEncryption=<false/>` on both iOS and macOS Info.plist | Unblock TestFlight internal testing while BIS / Apple paperwork is being prepared. Re-do per Steps 1-5 above before any public App Store release. |
|
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
**Document status:** DV meeting baseline candidate
|
**Document status:** DV meeting baseline candidate
|
||||||
**Date:** 2026-05-29
|
**Date:** 2026-05-29
|
||||||
**Candidate:** `v1.0.0-rc.1` evidence over workspace version `0.2.0-beta.1`
|
**Candidate:** documentation/DV evidence over Rust workspace version `0.2.0-beta.1`; Flutter app version/build `0.3.0+100`
|
||||||
**Decision:** No-Go for public/store release; Conditional Go only for documentation review and continued internal DV validation
|
**Decision:** No-Go for public/store release; Conditional Go only for documentation review and continued internal DV validation
|
||||||
|
|
||||||
## 1. Decision Summary
|
## 1. Decision Summary
|
||||||
@@ -23,10 +23,11 @@ The phrase `Conditional Go` in this record is restricted to document-baseline re
|
|||||||
| Field | Value |
|
| Field | Value |
|
||||||
|---|---|
|
|---|---|
|
||||||
| Workspace version | `0.2.0-beta.1` |
|
| Workspace version | `0.2.0-beta.1` |
|
||||||
| CHANGELOG latest candidate | `v1.0.0-rc.1` |
|
| Flutter app version/build | `0.3.0+100` from `apps/chanora_flutter/pubspec.yaml` |
|
||||||
| Build number | `76` from `apps/chanora_flutter/pubspec.yaml` |
|
| CHANGELOG latest candidate | `v0.3.0` baseline entry; public release version not reached |
|
||||||
|
| Build number | `100` from `apps/chanora_flutter/pubspec.yaml` |
|
||||||
| Commit SHA | To be recorded from the candidate build job before release approval |
|
| Commit SHA | To be recorded from the candidate build job before release approval |
|
||||||
| Git tag | To be recorded if `v1.0.0-rc.1` is tagged for release validation |
|
| Git tag | To be recorded if a candidate is tagged for release validation |
|
||||||
| Artifact hashes | Not recorded in current workspace; required before release approval |
|
| Artifact hashes | Not recorded in current workspace; required before release approval |
|
||||||
| Release owner | Product / Release Operations |
|
| Release owner | Product / Release Operations |
|
||||||
| Verification owner | Software QA with System Engineering support |
|
| Verification owner | Software QA with System Engineering support |
|
||||||
@@ -54,6 +55,7 @@ Current implementation status is summarized in `docs/implementation-status-2026-
|
|||||||
| Privacy policy baseline | Baseline candidate | Requires owner/legal review before public/store release |
|
| Privacy policy baseline | Baseline candidate | Requires owner/legal review before public/store release |
|
||||||
| Security/privacy evidence | Partial | Blocks strong secure-storage and diagnostic claims until audits attach evidence |
|
| Security/privacy evidence | Partial | Blocks strong secure-storage and diagnostic claims until audits attach evidence |
|
||||||
| Android secure-storage DEK | Deferred to v1.1 | Requires waiver for internal testing; limits release claim |
|
| Android secure-storage DEK | Deferred to v1.1 | Requires waiver for internal testing; limits release claim |
|
||||||
|
| Android target compile/install/smoke | Blocked locally | Missing Android NDK compiler `aarch64-linux-android-clang` and no authorized ADB target block Android runtime claims |
|
||||||
| iOS release build/signing | Unsigned verification only | Blocks TestFlight/App Store release |
|
| iOS release build/signing | Unsigned verification only | Blocks TestFlight/App Store release |
|
||||||
| macOS signing/notarization | Not complete | Blocks macOS public binary release |
|
| macOS signing/notarization | Not complete | Blocks macOS public binary release |
|
||||||
| Windows/Linux packaging | Source-buildable only for candidate | Blocks packaged public desktop release claims |
|
| Windows/Linux packaging | Source-buildable only for candidate | Blocks packaged public desktop release claims |
|
||||||
@@ -70,6 +72,7 @@ Current implementation status is summarized in `docs/implementation-status-2026-
|
|||||||
| iOS unsigned build | CI defined | Attach latest passing candidate run; add signing evidence before release |
|
| iOS unsigned build | CI defined | Attach latest passing candidate run; add signing evidence before release |
|
||||||
| Compatible-server demo | Evidence not attached in this record | Run and attach demo notes/logs |
|
| Compatible-server demo | Evidence not attached in this record | Run and attach demo notes/logs |
|
||||||
| Audio send/receive and processing demo | Evidence not attached in this record | Run and attach platform evidence |
|
| Audio send/receive and processing demo | Evidence not attached in this record | Run and attach platform evidence |
|
||||||
|
| Android target build/install/smoke | Blocked locally | Install/fix Android NDK compiler, connect/authorize a device or emulator, then attach build/install/smoke evidence |
|
||||||
| Diagnostics redaction/export demo | Evidence not attached in this record | Run and attach export review |
|
| Diagnostics redaction/export demo | Evidence not attached in this record | Run and attach export review |
|
||||||
| Platform secure-storage audit | Partial | Attach per-platform audit or waiver |
|
| Platform secure-storage audit | Partial | Attach per-platform audit or waiver |
|
||||||
| PTT capability evidence | Partial | Attach per-platform `PttCapabilityLevel` and backend record |
|
| PTT capability evidence | Partial | Attach per-platform `PttCapabilityLevel` and backend record |
|
||||||
@@ -78,7 +81,7 @@ Current implementation status is summarized in `docs/implementation-status-2026-
|
|||||||
|
|
||||||
| Platform | Current readiness | Release decision |
|
| Platform | Current readiness | Release decision |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| Android | Core platform implementation present; Android Keystore-backed DEK deferred | Conditional internal validation only |
|
| Android | Core platform implementation present; Android Keystore-backed DEK deferred; local target compile/install/smoke blocked by missing `aarch64-linux-android-clang` and no authorized ADB target | Conditional internal validation only after Android build/install/smoke evidence or explicit waiver |
|
||||||
| iOS | Unsigned build path present; signing and store pipeline incomplete | No-Go for store release |
|
| iOS | Unsigned build path present; signing and store pipeline incomplete | No-Go for store release |
|
||||||
| Windows | Source-buildable; smoke procedure exists | No-Go for packaged release until smoke/signing evidence exists |
|
| Windows | Source-buildable; smoke procedure exists | No-Go for packaged release until smoke/signing evidence exists |
|
||||||
| macOS | Source-buildable; public artifact not in candidate | No-Go for packaged release until signing/notarization evidence exists |
|
| macOS | Source-buildable; public artifact not in candidate | No-Go for packaged release until signing/notarization evidence exists |
|
||||||
|
|||||||
+1
-1
@@ -1317,7 +1317,7 @@ Therefore:
|
|||||||
- Analysis: Feasible with current Flutter + Rust Core architecture; refine in SAD/SDD as needed.
|
- Analysis: Feasible with current Flutter + Rust Core architecture; refine in SAD/SDD as needed.
|
||||||
- Owner: Software Team
|
- Owner: Software Team
|
||||||
|
|
||||||
**SRS-110**: The iOS software shall integrate with AVAudioSession or equivalent platform audio session behavior for foreground voice sessions.
|
**SRS-110**: The iOS software shall integrate with AVAudioSession or equivalent platform audio session behavior for foreground voice sessions. The session shall be configured for VoIP (`.playAndRecord` + `.voiceChat` + `.mixWithOthers`) only while a voice channel is active, and shall return to a non-disruptive idle state (`.ambient`, inactive, with `.notifyOthersOnDeactivation`) at all other times so that other apps' audio (music, podcasts, navigation) is preserved when the user opens Chanora to read text chat.
|
||||||
|
|
||||||
- Type: Platform / iOS
|
- Type: Platform / iOS
|
||||||
- Stage: P0 / MVP
|
- Stage: P0 / MVP
|
||||||
|
|||||||
@@ -0,0 +1,95 @@
|
|||||||
|
# Core Internal Split Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Improve `chanora_core` maintainability by moving stable event DTOs and network diagnostic state out of the oversized `lib.rs` while preserving the public crate Interface.
|
||||||
|
|
||||||
|
**Architecture:** Keep `lib.rs` as the public Interface and session orchestration entry point. Move event-facing DTOs to `events.rs` and private network diagnostic ring-buffer state to `network_diagnostics.rs`; re-export public event types from `lib.rs` so downstream callers keep using `chanora_core::SessionEvent` and related names unchanged.
|
||||||
|
|
||||||
|
**Tech Stack:** Rust 2021, Tokio broadcast/watch channels, `thiserror`, existing Cargo workspace tests.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: Move Core Event DTOs
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `core/chanora_core/src/events.rs`
|
||||||
|
- Modify: `core/chanora_core/src/lib.rs`
|
||||||
|
- Verify: `cargo test -p chanora_core`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Preserve current public Interface with tests**
|
||||||
|
|
||||||
|
Run: `cargo test -p chanora_core`
|
||||||
|
Expected: PASS. Existing bridge-facing tests and compile checks prove the current public event names are valid.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Create `events.rs` with the moved public event DTOs**
|
||||||
|
|
||||||
|
Move these exact public types from `lib.rs` to `events.rs`:
|
||||||
|
- `PttDescriptorSnapshot`
|
||||||
|
- `PersistedPttBinding`
|
||||||
|
- `SessionEvent`
|
||||||
|
- `VoiceJoinSyncState`
|
||||||
|
- `VoiceJoinErrorCode`
|
||||||
|
- `NetworkState`
|
||||||
|
|
||||||
|
Use local imports in `events.rs` for `chanora_audio::{AudioRoute, PttBackendDescriptor}` and `chanora_protocol::MessageTarget`.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Re-export moved types from `lib.rs`**
|
||||||
|
|
||||||
|
Add `mod events;` and `pub use events::{...};` for all moved public types. Remove the original definitions from `lib.rs`.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run tests**
|
||||||
|
|
||||||
|
Run: `cargo test -p chanora_core`
|
||||||
|
Expected: PASS with no public Interface break.
|
||||||
|
|
||||||
|
### Task 2: Move Core Network Diagnostics State
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `core/chanora_core/src/network_diagnostics.rs`
|
||||||
|
- Modify: `core/chanora_core/src/lib.rs`
|
||||||
|
- Verify: `cargo test -p chanora_core network_diagnostics`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Move `NetworkDiagnostics` into a private module**
|
||||||
|
|
||||||
|
Move the private `NetworkDiagnostics` struct and its methods from `lib.rs` into `network_diagnostics.rs`. Keep methods `pub(crate)` because `ChanoraSession` records connection/loss events and exports summaries.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Move the regression test with the module**
|
||||||
|
|
||||||
|
Move `network_diagnostics_keeps_last_eight_loss_reasons` from the `lib.rs` test module into `network_diagnostics.rs` so the behaviour test lives next to the Implementation it protects.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Import the private module from `lib.rs`**
|
||||||
|
|
||||||
|
Add `mod network_diagnostics;` and `use network_diagnostics::NetworkDiagnostics;`. Remove `VecDeque` from the `lib.rs` imports.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run targeted and workspace verification**
|
||||||
|
|
||||||
|
Run: `cargo test -p chanora_core network_diagnostics`
|
||||||
|
Expected: PASS.
|
||||||
|
|
||||||
|
Run: `cargo test --workspace`
|
||||||
|
Expected: PASS.
|
||||||
|
|
||||||
|
### Task 3: Format and Check Workspace
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: Rust files touched above only, except existing formatter-only churn may remain from prior `cargo fmt --all`.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Format Rust code**
|
||||||
|
|
||||||
|
Run: `cargo fmt --all`
|
||||||
|
Expected: no command output.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Compile workspace**
|
||||||
|
|
||||||
|
Run: `cargo check --workspace`
|
||||||
|
Expected: finishes successfully.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Inspect diff**
|
||||||
|
|
||||||
|
Run: `git diff --stat`
|
||||||
|
Expected: new `events.rs` and `network_diagnostics.rs`; smaller `core/chanora_core/src/lib.rs`; no public API renames.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Self-review: This plan covers the recommended Core split first slice, avoids public Interface changes, has no placeholders, and keeps testing tied to the moved Implementations.
|
||||||
@@ -0,0 +1,608 @@
|
|||||||
|
# Maintainability Continuation Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Continue the current maintainability review with safe simplifications, full code-review remediation, current documentation, explicit fail-safe evidence, and Android runtime verification status.
|
||||||
|
|
||||||
|
**Current status:** Task 0 and the audio-realtime portion of Task 0.1 have landed in commits `d835394` and `8606eb4`. Task 0.2 is a documentation/status alignment slice only; it must not claim Android or iOS runtime success.
|
||||||
|
|
||||||
|
**Architecture:** Treat the existing uncommitted maintainability changes as the baseline slice. Fix safety and hidden-bug findings before broad Module splits. Preserve public Rust Core, Bridge, Protocol, Audio, and Flutter responsibilities while applying only tested simplifications and risk-reducing refactors. Record larger seam decisions as follow-up findings unless a huge Module must be split to make a safety fix testable.
|
||||||
|
|
||||||
|
**Tech Stack:** Rust 2021 Cargo workspace, Flutter/Dart 3.11, Flutter Rust Bridge 2.12, Android ADB, Markdown governance and verification documents.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File Structure
|
||||||
|
|
||||||
|
Implementation should keep the following responsibilities stable:
|
||||||
|
|
||||||
|
- `core/chanora_core/src/lib.rs`: public Core API, session orchestration, public re-exports, and integration-facing methods.
|
||||||
|
- `core/chanora_core/src/events.rs`: Core public event DTOs re-exported by `lib.rs`.
|
||||||
|
- `core/chanora_core/src/network_diagnostics.rs`: private bounded network diagnostic history and its local regression tests.
|
||||||
|
- `crates/chanora_audio/src/voice_render.rs`: shared render/downmix helpers.
|
||||||
|
- `crates/chanora_audio/src/ios_raw_unit.rs`, `ios_voice_unit.rs`, `android_voice_unit.rs`, `engine.rs`: platform/audio backends; avoid broad rewrites without device evidence.
|
||||||
|
- `crates/chanora_audio/src/ptt_backends/mod.rs`: PTT backend descriptor and error definitions.
|
||||||
|
- `crates/chanora_state/src/lib.rs`: snapshot reducers and state deltas.
|
||||||
|
- `crates/chanora_protocol/src/adapter.rs`: protocol adapter event ordering and DTO projection.
|
||||||
|
- `crates/chanora_bridge/src/api.rs`: bridge-facing API and DTO mapping; generated files are not manually edited.
|
||||||
|
- `crates/chanora_diagnostics/src/lib.rs`: bounded diagnostics, redaction, and export data.
|
||||||
|
- `docs/governance/maintainability-review-2026-06-08.md`: working review record and fail-safe gap log.
|
||||||
|
- `docs/governance/document-index.md`: navigation index for review records.
|
||||||
|
- `docs/architecture/sad.md`, `docs/architecture/sdd.md`: architecture updates for seams and implementation boundaries.
|
||||||
|
- `docs/implementation-status-2026-05-28.md`: implementation status updates.
|
||||||
|
- `docs/verification/swe4-unit-verification-plan.md`, `docs/verification/swe5-software-integration-verification-plan.md`: verification evidence and requirements updates.
|
||||||
|
|
||||||
|
## Review Findings To Remediate First
|
||||||
|
|
||||||
|
The full review added these priority fixes before the original maintainability cleanup sequence:
|
||||||
|
|
||||||
|
- Flutter privacy fail-safe: talk-power recovery must not clear a user/manual mute. Fixed in commit `d835394`.
|
||||||
|
- Flutter stuck-transmit fail-safe: touch PTT must release when disposed while held. Fixed in commit `d835394`.
|
||||||
|
- Flutter/iOS fail-safe: iOS audio-session activation errors must be caught, and activation should be moved before Rust VoiceProcessingIO startup where the current app flow allows. Missing-plugin/error hardening fixed in commit `d835394`; iOS device runtime verification remains required.
|
||||||
|
- Rust realtime safety: Android and iOS raw render-reference buffers must not use unsynchronized mutable aliasing. Callback-path hardening fixed in commit `8606eb4`; the full lock-free `AudioHandler` / config / debug-recorder redesign remains a follow-up.
|
||||||
|
- Rust realtime safety: Android input callback must not block on a mutex, and non-48 kHz capture should not allocate/clone per callback. Focused callback-path hardening fixed in commit `8606eb4`; Android target compilation and runtime verification remain blocked locally until the missing NDK compiler and an authorized ADB target are available.
|
||||||
|
- Rust control-plane safety: disconnect and protocol control requests must remain bounded under broken transport or sustained voice traffic.
|
||||||
|
- Governance correctness: README/release/VAD/Android API/product-decision docs must not contradict code or verification status.
|
||||||
|
|
||||||
|
## Task 0: Fix Flutter Privacy and Stuck-Transmit Fail-Safes
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `apps/chanora_flutter/lib/main.dart`
|
||||||
|
- Modify: `apps/chanora_flutter/lib/widgets/voice_compact.dart`
|
||||||
|
- Modify: `apps/chanora_flutter/lib/services/ios_audio_session_controller.dart`
|
||||||
|
- Test: `apps/chanora_flutter/test/widgets/voice_compact_test.dart`
|
||||||
|
- Test: existing Flutter tests under `apps/chanora_flutter/test/`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add failing touch PTT disposal regression test**
|
||||||
|
|
||||||
|
Create or update `apps/chanora_flutter/test/widgets/voice_compact_test.dart` with a widget test that presses the touch PTT button, replaces the widget without sending pointer-up, and expects the callback sequence `[true, false]`.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run touch PTT test and verify RED**
|
||||||
|
|
||||||
|
Run from `apps/chanora_flutter`: `flutter test test/widgets/voice_compact_test.dart`
|
||||||
|
|
||||||
|
Expected before production fix: FAIL because disposal does not emit `false`.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Implement touch PTT release-on-dispose**
|
||||||
|
|
||||||
|
Add `dispose()` to the touch PTT button state so an active press calls `widget.onHeldChanged(false)` exactly once before disposal.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run touch PTT test and verify GREEN**
|
||||||
|
|
||||||
|
Run from `apps/chanora_flutter`: `flutter test test/widgets/voice_compact_test.dart`
|
||||||
|
|
||||||
|
Expected after fix: PASS.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Add or preserve mute-owner regression coverage**
|
||||||
|
|
||||||
|
If an existing pure reducer seam is available, add a failing test for manual mute true -> talk power blocked -> talk power restored. If no testable seam exists, first extract the smallest voice mute owner helper from `main.dart` and test it directly.
|
||||||
|
|
||||||
|
- [ ] **Step 6: Implement independent mute owners**
|
||||||
|
|
||||||
|
Ensure talk-power recovery clears only the talk-power owner and does not clear manual/user mute or permission mute. Effective hard mute is the OR of manual, permission, and talk-power owners.
|
||||||
|
|
||||||
|
- [ ] **Step 7: Harden iOS audio-session controller errors**
|
||||||
|
|
||||||
|
Add tests for `MissingPluginException` in `ios_audio_session_controller_test.dart`, then catch `MissingPluginException` or `Object` so activation/deactivation failures do not become unhandled async errors.
|
||||||
|
|
||||||
|
- [ ] **Step 8: Run focused Flutter verification**
|
||||||
|
|
||||||
|
Run from `apps/chanora_flutter`: `flutter test test/widgets/voice_compact_test.dart test/services/ios_audio_session_controller_test.dart && flutter analyze`
|
||||||
|
|
||||||
|
Expected: PASS.
|
||||||
|
|
||||||
|
- [ ] **Step 9: Commit Flutter fail-safe slice**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add apps/chanora_flutter/lib/main.dart apps/chanora_flutter/lib/widgets/voice_compact.dart apps/chanora_flutter/lib/services/ios_audio_session_controller.dart apps/chanora_flutter/test/widgets/voice_compact_test.dart apps/chanora_flutter/test/services/ios_audio_session_controller_test.dart
|
||||||
|
git commit -m "fix(voice): preserve mute owners and release touch ptt"
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: one commit containing only Flutter fail-safe fixes and tests.
|
||||||
|
|
||||||
|
## Task 0.1: Fix Rust Realtime and Control-Plane Safety Findings
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `crates/chanora_audio/src/android_voice_unit.rs`
|
||||||
|
- Modify: `crates/chanora_audio/src/ios_raw_unit.rs`
|
||||||
|
- Modify: `crates/chanora_audio/src/engine.rs`
|
||||||
|
- Modify: `crates/chanora_protocol/src/adapter.rs`
|
||||||
|
- Modify: `core/chanora_core/src/lib.rs`
|
||||||
|
- Test: Rust tests in affected crates
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add failing bounded-buffer regression for render-reference handoff**
|
||||||
|
|
||||||
|
Add host-testable unit coverage around the render-reference buffer behavior so a writer can publish a frame and a reader can read a complete latest frame without unsynchronized mutation.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Replace unsafe shared mutable render-reference buffers**
|
||||||
|
|
||||||
|
Replace unsynchronized mutable aliasing in Android and iOS raw render-reference buffers with a realtime-safe handoff such as an `ArrayQueue` of complete frames or a documented atomic double-buffer. Do not add mutex locking to realtime callbacks.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Add failing protocol progress regression where feasible**
|
||||||
|
|
||||||
|
Add or isolate a test proving control requests are not starved by sustained voice packet drain.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Bound voice draining and disconnect shutdown**
|
||||||
|
|
||||||
|
Cap voice packet draining per protocol loop and make disconnect/shutdown bounded so UI/Core locks are not held across unbounded transport waits.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Run focused Rust verification**
|
||||||
|
|
||||||
|
Run: `cargo test -p chanora_audio -p chanora_protocol -p chanora_core`
|
||||||
|
|
||||||
|
Expected: PASS.
|
||||||
|
|
||||||
|
- [ ] **Step 6: Commit Rust safety slice**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add crates/chanora_audio/src/android_voice_unit.rs crates/chanora_audio/src/ios_raw_unit.rs crates/chanora_audio/src/engine.rs crates/chanora_protocol/src/adapter.rs core/chanora_core/src/lib.rs
|
||||||
|
git commit -m "fix(audio): harden realtime and protocol fail-safes"
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: one commit containing only Rust safety fixes and tests.
|
||||||
|
|
||||||
|
## Task 0.2: Align Review Findings With Specs, Plans, and Governance Docs
|
||||||
|
|
||||||
|
**Status:** In progress / documentation-only alignment. Do not commit from this task unless explicitly requested.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `docs/superpowers/specs/2026-06-08-maintainability-continuation-design.md`
|
||||||
|
- Modify: `docs/superpowers/plans/2026-06-08-maintainability-continuation.md`
|
||||||
|
- Modify: `docs/governance/maintainability-review-2026-06-08.md`
|
||||||
|
- Modify: `README.md`
|
||||||
|
- Modify if needed: `CHANGELOG.md`
|
||||||
|
- Modify if needed: `docs/governance/product-decision-register.md`
|
||||||
|
- Modify if needed: `docs/release/release-readiness-go-nogo-record.md`
|
||||||
|
- Modify if needed: `docs/release/dv-waiver-register.md`
|
||||||
|
- Modify if needed: `docs/verification/verification-master-plan.md`
|
||||||
|
- Modify if needed: `docs/verification/sys4-system-integration-verification-plan.md`
|
||||||
|
|
||||||
|
- [x] **Step 1: Record full-review findings in maintainability review**
|
||||||
|
|
||||||
|
Update `docs/governance/maintainability-review-2026-06-08.md` with the full code-review findings, fixed items, blocked items, and follow-up Module split candidates.
|
||||||
|
|
||||||
|
- [x] **Step 2: Fix stale platform/release claims**
|
||||||
|
|
||||||
|
Update Android minimum runtime claims to API 28 where code and requirements require it. Update release metadata so Flutter app version/build and Rust workspace version are clearly distinguished.
|
||||||
|
|
||||||
|
- [x] **Step 3: Clarify VAD/VoiceActivity status**
|
||||||
|
|
||||||
|
Document the difference between VAD scaffolding/assets/tests and product-enabled VoiceActivity behavior. Do not claim runtime VoiceActivity is shipped unless verified.
|
||||||
|
|
||||||
|
- [x] **Step 4: Promote Android runtime verification blocker**
|
||||||
|
|
||||||
|
Add Android ADB/build/install/smoke as a blocker or waiver in governing release/verification docs when no authorized target is connected.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit plan/spec/governance alignment slice**
|
||||||
|
|
||||||
|
Skipped in this subagent run because the instruction for Task 0.2 explicitly says not to commit.
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add docs/superpowers/specs/2026-06-08-maintainability-continuation-design.md docs/superpowers/plans/2026-06-08-maintainability-continuation.md docs/governance/maintainability-review-2026-06-08.md README.md CHANGELOG.md docs/governance/product-decision-register.md docs/release/release-readiness-go-nogo-record.md docs/release/dv-waiver-register.md docs/verification/verification-master-plan.md docs/verification/sys4-system-integration-verification-plan.md
|
||||||
|
git commit -m "docs: align review findings and verification gates"
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: one documentation/governance commit, with unavailable optional files omitted only if unchanged.
|
||||||
|
|
||||||
|
## Task 1: Verify Current Branch Baseline
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Read: `docs/governance/maintainability-review-2026-06-08.md`
|
||||||
|
- Read: `docs/superpowers/plans/2026-06-08-core-internal-split.md`
|
||||||
|
- Inspect: all currently modified files from `git status --short`
|
||||||
|
- Modify: none unless verification exposes a small mechanical fix
|
||||||
|
|
||||||
|
- [ ] **Step 1: Inspect current status**
|
||||||
|
|
||||||
|
Run: `git status --short`
|
||||||
|
|
||||||
|
Expected: output includes the existing maintainability branch changes and no staged files from unrelated work.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Inspect current diff summary**
|
||||||
|
|
||||||
|
Run: `git diff --stat`
|
||||||
|
|
||||||
|
Expected: diff remains focused on Core split, Audio simplifications, State/Protocol/Bridge/Diagnostics cleanup, and documentation updates.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Verify Rust formatting**
|
||||||
|
|
||||||
|
Run: `cargo fmt --all --check`
|
||||||
|
|
||||||
|
Expected: PASS with no output. If it fails, run `cargo fmt --all`, inspect the resulting diff, and include formatter-only changes in the smallest relevant commit.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Verify Rust compilation**
|
||||||
|
|
||||||
|
Run: `cargo check --workspace`
|
||||||
|
|
||||||
|
Expected: PASS for the full workspace.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Verify Rust tests**
|
||||||
|
|
||||||
|
Run: `cargo test --workspace`
|
||||||
|
|
||||||
|
Expected: PASS for the full workspace.
|
||||||
|
|
||||||
|
- [ ] **Step 6: Verify Flutter analysis**
|
||||||
|
|
||||||
|
Run from `apps/chanora_flutter`: `flutter analyze`
|
||||||
|
|
||||||
|
Expected: PASS with no new analyzer errors.
|
||||||
|
|
||||||
|
- [ ] **Step 7: Verify Flutter tests**
|
||||||
|
|
||||||
|
Run from `apps/chanora_flutter`: `flutter test --exclude-tags e2e`
|
||||||
|
|
||||||
|
Expected: PASS for non-e2e Flutter tests.
|
||||||
|
|
||||||
|
- [ ] **Step 8: Check Android device availability**
|
||||||
|
|
||||||
|
Run: `adb devices -l`
|
||||||
|
|
||||||
|
Expected if a target is connected: at least one `device` row. Expected if no target is connected: only the header and no `device` row; record Android runtime verification as blocked in `docs/governance/maintainability-review-2026-06-08.md`.
|
||||||
|
|
||||||
|
- [ ] **Step 9: Commit verified existing slice**
|
||||||
|
|
||||||
|
Only after Steps 1-8 have been completed or any blocked Android status has been documented, stage the smallest coherent existing slice.
|
||||||
|
|
||||||
|
Recommended first slice if tests pass:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add core/chanora_core/src/lib.rs core/chanora_core/src/events.rs core/chanora_core/src/network_diagnostics.rs docs/superpowers/plans/2026-06-08-core-internal-split.md
|
||||||
|
git commit -m "refactor(core): split event and diagnostics internals"
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: one commit containing only the Core split and its plan.
|
||||||
|
|
||||||
|
## Task 2: Commit Existing Built-In and Helper Reuse Simplifications
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify or stage: `crates/chanora_audio/src/ptt_backends/mod.rs`
|
||||||
|
- Modify or stage: `crates/chanora_audio/src/voice_render.rs`
|
||||||
|
- Modify or stage: `crates/chanora_audio/src/ios_raw_unit.rs`
|
||||||
|
- Modify or stage: `crates/chanora_diagnostics/src/lib.rs`
|
||||||
|
- Modify or stage: `crates/chanora_state/src/lib.rs`
|
||||||
|
- Modify or stage: `crates/chanora_resolver/Cargo.toml`
|
||||||
|
- Modify or stage: `Cargo.lock`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Inspect simplification diffs**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git diff -- crates/chanora_audio/src/ptt_backends/mod.rs crates/chanora_audio/src/voice_render.rs crates/chanora_audio/src/ios_raw_unit.rs crates/chanora_diagnostics/src/lib.rs crates/chanora_state/src/lib.rs crates/chanora_resolver/Cargo.toml Cargo.lock
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: diffs show built-in/helper reuse only: `thiserror::Error`, `VecDeque`, render helper reuse, reducer reuse, and workspace metadata inheritance.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run focused Rust tests for changed areas**
|
||||||
|
|
||||||
|
Run: `cargo test -p chanora_audio -p chanora_diagnostics -p chanora_state -p chanora_resolver`
|
||||||
|
|
||||||
|
Expected: PASS for all listed crates.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Run workspace Rust verification**
|
||||||
|
|
||||||
|
Run: `cargo check --workspace && cargo test --workspace`
|
||||||
|
|
||||||
|
Expected: PASS for workspace compile and tests.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit built-in/helper reuse slice**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add crates/chanora_audio/src/ptt_backends/mod.rs crates/chanora_audio/src/voice_render.rs crates/chanora_audio/src/ios_raw_unit.rs crates/chanora_diagnostics/src/lib.rs crates/chanora_state/src/lib.rs crates/chanora_resolver/Cargo.toml Cargo.lock
|
||||||
|
git commit -m "refactor: reuse built-ins and shared helpers"
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: one commit containing only the built-in/helper reuse simplifications.
|
||||||
|
|
||||||
|
## Task 3: Review Remaining Audio Platform Diffs Before Committing
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Inspect: `crates/chanora_audio/src/android_voice_unit.rs`
|
||||||
|
- Inspect: `crates/chanora_audio/src/engine.rs`
|
||||||
|
- Inspect: `crates/chanora_audio/src/ios_voice_unit.rs`
|
||||||
|
- Modify: `docs/governance/maintainability-review-2026-06-08.md` if Android runtime verification is blocked or audio fail-safe evidence changes
|
||||||
|
|
||||||
|
- [ ] **Step 1: Inspect platform-audio diffs**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git diff -- crates/chanora_audio/src/android_voice_unit.rs crates/chanora_audio/src/engine.rs crates/chanora_audio/src/ios_voice_unit.rs
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: diffs are understandable as local simplification or fail-safe improvements. If a diff changes platform runtime behavior and no Android/iOS device evidence is available, keep it separate from non-platform commits.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run audio crate tests**
|
||||||
|
|
||||||
|
Run: `cargo test -p chanora_audio`
|
||||||
|
|
||||||
|
Expected: PASS.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Check Android runtime availability**
|
||||||
|
|
||||||
|
Run: `adb devices -l`
|
||||||
|
|
||||||
|
Expected if connected: at least one target row with `device`. Expected if blocked: no target row.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run Android build when a target is available**
|
||||||
|
|
||||||
|
Run from `apps/chanora_flutter`: `flutter build apk --debug`
|
||||||
|
|
||||||
|
Expected: PASS and a debug APK is produced.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Run Android install and smoke when a target is available**
|
||||||
|
|
||||||
|
Run from `apps/chanora_flutter`: `flutter install`
|
||||||
|
|
||||||
|
Expected: PASS and app installs on the connected target.
|
||||||
|
|
||||||
|
Manual smoke expectations:
|
||||||
|
|
||||||
|
- App launches without crash.
|
||||||
|
- Permission UI can be reached.
|
||||||
|
- Audio device/permission screen does not crash.
|
||||||
|
- Voice controls remain responsive.
|
||||||
|
|
||||||
|
- [ ] **Step 6: Record blocked Android evidence if no target is available**
|
||||||
|
|
||||||
|
Modify `docs/governance/maintainability-review-2026-06-08.md` Section 6 so it includes the exact `adb devices -l` result and states Android runtime verification is blocked until a device or emulator is connected.
|
||||||
|
|
||||||
|
- [ ] **Step 7: Commit platform-audio slice**
|
||||||
|
|
||||||
|
If Android runtime was verified, run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add crates/chanora_audio/src/android_voice_unit.rs crates/chanora_audio/src/engine.rs crates/chanora_audio/src/ios_voice_unit.rs docs/governance/maintainability-review-2026-06-08.md
|
||||||
|
git commit -m "refactor(audio): simplify platform voice internals"
|
||||||
|
```
|
||||||
|
|
||||||
|
If Android runtime was blocked, run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add crates/chanora_audio/src/android_voice_unit.rs crates/chanora_audio/src/engine.rs crates/chanora_audio/src/ios_voice_unit.rs docs/governance/maintainability-review-2026-06-08.md
|
||||||
|
git commit -m "refactor(audio): simplify platform voice internals"
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: commit message is the same, but the maintainability review explicitly records the blocked Android runtime evidence.
|
||||||
|
|
||||||
|
## Task 4: Review Protocol and Bridge Diffs as One Boundary Slice
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Inspect: `crates/chanora_protocol/src/adapter.rs`
|
||||||
|
- Inspect: `crates/chanora_bridge/src/api.rs`
|
||||||
|
- Modify: `docs/architecture/sad.md`
|
||||||
|
- Modify: `docs/architecture/sdd.md`
|
||||||
|
- Modify: `docs/governance/maintainability-review-2026-06-08.md`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Inspect protocol/bridge diffs**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git diff -- crates/chanora_protocol/src/adapter.rs crates/chanora_bridge/src/api.rs
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: diffs preserve protocol isolation and bridge DTO shape unless bridge generation and Flutter tests are included.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Verify protocol tests**
|
||||||
|
|
||||||
|
Run: `cargo test -p chanora_protocol`
|
||||||
|
|
||||||
|
Expected: PASS.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Verify bridge tests and compile**
|
||||||
|
|
||||||
|
Run: `cargo test -p chanora_bridge`
|
||||||
|
|
||||||
|
Expected: PASS.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Verify Flutter after bridge/API changes**
|
||||||
|
|
||||||
|
Run from `apps/chanora_flutter`: `flutter analyze && flutter test --exclude-tags e2e`
|
||||||
|
|
||||||
|
Expected: PASS for analyzer and non-e2e tests.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Document seam findings**
|
||||||
|
|
||||||
|
Update `docs/governance/maintainability-review-2026-06-08.md` so remaining bridge DTO drift and protocol voice packet seam risks are listed under fail-safe gaps or follow-up opportunities.
|
||||||
|
|
||||||
|
- [ ] **Step 6: Update architecture docs if seam wording changed**
|
||||||
|
|
||||||
|
If the code diff clarifies protocol/bridge boundaries, update `docs/architecture/sad.md` and `docs/architecture/sdd.md` with one concise note each. The note should state whether protocol voice packet handling is an intentional exception and whether bridge DTO mirrors remain required by Flutter Rust Bridge.
|
||||||
|
|
||||||
|
- [ ] **Step 7: Commit protocol/bridge slice**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add crates/chanora_protocol/src/adapter.rs crates/chanora_bridge/src/api.rs docs/architecture/sad.md docs/architecture/sdd.md docs/governance/maintainability-review-2026-06-08.md
|
||||||
|
git commit -m "refactor: clarify protocol bridge boundaries"
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: one commit for protocol/bridge boundary cleanup plus matching architecture documentation.
|
||||||
|
|
||||||
|
## Task 5: Run a Second-Pass Simplification Search
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Inspect: Rust and Dart source files only
|
||||||
|
- Modify: only if the simplification is mechanical, local, and covered by tests
|
||||||
|
- Modify: `docs/governance/maintainability-review-2026-06-08.md`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Search for custom queue front removal**
|
||||||
|
|
||||||
|
Run: `rg "remove\(0\)|removeAt\(0\)" core crates apps/chanora_flutter/lib apps/chanora_flutter/test`
|
||||||
|
|
||||||
|
Expected: no results. If results exist, replace with `VecDeque` in Rust or a clearer Dart queue structure only when behavior is covered by a local test.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Search for manual Rust error formatting**
|
||||||
|
|
||||||
|
Run: `rg "impl (std::fmt::)?Display for .*Error|impl std::error::Error for" core crates`
|
||||||
|
|
||||||
|
Expected: only intentional manual implementations remain. For simple enum error types, replace with `thiserror::Error` and add or preserve tests for user-facing strings.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Search for duplicate mono downmix loops**
|
||||||
|
|
||||||
|
Run: `rg "chunks_exact\(2\)|downmix|mono" crates/chanora_audio/src`
|
||||||
|
|
||||||
|
Expected: duplicate i16/f32 mono downmix code is either absent or justified. If a duplicate remains, route it through an existing helper and run `cargo test -p chanora_audio`.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Search for shallow modules worth documenting**
|
||||||
|
|
||||||
|
Run: `rg "^pub struct|^pub enum|^pub fn|^fn" crates/chanora_audio/src/processor crates/chanora_prefetch/src crates/chanora_resolver/src core/chanora_core/src`
|
||||||
|
|
||||||
|
Expected: identify candidates, but do not merge modules in this task. Record speculative merges in `docs/governance/maintainability-review-2026-06-08.md` unless a candidate is trivial and already covered by tests.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit second-pass mechanical simplifications if any**
|
||||||
|
|
||||||
|
If code changed, run the relevant focused tests plus `cargo check --workspace && cargo test --workspace`, then inspect the changed files:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git status --short
|
||||||
|
```
|
||||||
|
|
||||||
|
Stage only the files changed by the second-pass mechanical simplification. Example for a Rust-only diagnostics simplification:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add crates/chanora_diagnostics/src/lib.rs docs/governance/maintainability-review-2026-06-08.md
|
||||||
|
git commit -m "refactor: apply second-pass mechanical simplifications"
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: commit contains only local mechanical simplifications and the review record.
|
||||||
|
|
||||||
|
- [ ] **Step 6: Commit review-only findings if no code changed**
|
||||||
|
|
||||||
|
If no code changed and only findings were added, run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add docs/governance/maintainability-review-2026-06-08.md
|
||||||
|
git commit -m "docs: record maintainability follow-up findings"
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: documentation-only commit.
|
||||||
|
|
||||||
|
## Task 6: Final Documentation Alignment
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `docs/governance/maintainability-review-2026-06-08.md`
|
||||||
|
- Modify: `docs/governance/document-index.md`
|
||||||
|
- Modify: `docs/implementation-status-2026-05-28.md`
|
||||||
|
- Modify: `docs/verification/swe4-unit-verification-plan.md`
|
||||||
|
- Modify: `docs/verification/swe5-software-integration-verification-plan.md`
|
||||||
|
- Modify if needed: `docs/architecture/sad.md`
|
||||||
|
- Modify if needed: `docs/architecture/sdd.md`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Update maintainability review completion status**
|
||||||
|
|
||||||
|
Edit `docs/governance/maintainability-review-2026-06-08.md` so these sections are current:
|
||||||
|
|
||||||
|
- Changes applied
|
||||||
|
- Remaining simplification opportunities
|
||||||
|
- Fail-safe gaps that need evidence
|
||||||
|
- Verification policy
|
||||||
|
- Android ADB status
|
||||||
|
- Git policy
|
||||||
|
|
||||||
|
- [ ] **Step 2: Update document index**
|
||||||
|
|
||||||
|
Ensure `docs/governance/document-index.md` includes `docs/governance/maintainability-review-2026-06-08.md` and this plan/spec if the repository convention indexes superpowers documents.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Update implementation status**
|
||||||
|
|
||||||
|
Ensure `docs/implementation-status-2026-05-28.md` describes maintainability review results without claiming production readiness.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Update SWE.4 verification plan**
|
||||||
|
|
||||||
|
Ensure `docs/verification/swe4-unit-verification-plan.md` lists Rust unit verification expectations for Core, Audio, State, Protocol, Bridge, Diagnostics, Resolver, and Prefetch when those crates are touched.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Update SWE.5 verification plan**
|
||||||
|
|
||||||
|
Ensure `docs/verification/swe5-software-integration-verification-plan.md` lists integration expectations for Bridge DTO drift, protocol event folding, Flutter analyze/test, and Android runtime smoke evidence.
|
||||||
|
|
||||||
|
- [ ] **Step 6: Run documentation cross-link search**
|
||||||
|
|
||||||
|
Run: `rg "maintainability-review-2026-06-08|core-internal-split|maintainability-continuation" docs README.md`
|
||||||
|
|
||||||
|
Expected: references point to existing files and no stale path is introduced.
|
||||||
|
|
||||||
|
- [ ] **Step 7: Commit final documentation alignment**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add docs/governance/maintainability-review-2026-06-08.md docs/governance/document-index.md docs/implementation-status-2026-05-28.md docs/verification/swe4-unit-verification-plan.md docs/verification/swe5-software-integration-verification-plan.md docs/architecture/sad.md docs/architecture/sdd.md
|
||||||
|
git commit -m "docs: align maintainability verification records"
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: one documentation-focused commit.
|
||||||
|
|
||||||
|
## Task 7: Final Verification and Android Evidence
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `docs/governance/maintainability-review-2026-06-08.md` only if final verification status changes
|
||||||
|
|
||||||
|
- [ ] **Step 1: Run full Rust verification**
|
||||||
|
|
||||||
|
Run: `cargo fmt --all --check && cargo check --workspace && cargo test --workspace`
|
||||||
|
|
||||||
|
Expected: PASS.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run full Flutter verification**
|
||||||
|
|
||||||
|
Run from `apps/chanora_flutter`: `flutter analyze && flutter test --exclude-tags e2e`
|
||||||
|
|
||||||
|
Expected: PASS.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Run ADB check**
|
||||||
|
|
||||||
|
Run: `adb devices -l`
|
||||||
|
|
||||||
|
Expected if connected: at least one target row with `device`. Expected if blocked: no target row and the maintainability review states Android runtime verification is blocked.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run Android build/install/smoke when connected**
|
||||||
|
|
||||||
|
Run from `apps/chanora_flutter`: `flutter build apk --debug && flutter install`
|
||||||
|
|
||||||
|
Expected: PASS. Manually verify app launch, permission screen access, audio settings access, and voice control responsiveness.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit final verification evidence if docs changed**
|
||||||
|
|
||||||
|
If the maintainability review was updated with final verification evidence, run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add docs/governance/maintainability-review-2026-06-08.md
|
||||||
|
git commit -m "docs: record maintainability verification evidence"
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: one small evidence-only documentation commit.
|
||||||
|
|
||||||
|
- [ ] **Step 6: Inspect final history and status**
|
||||||
|
|
||||||
|
Run: `git status --short && git log --oneline -10`
|
||||||
|
|
||||||
|
Expected: no unexpected unstaged changes related to this work; recent commits are small and logically separated.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Self-Review
|
||||||
|
|
||||||
|
Spec coverage:
|
||||||
|
|
||||||
|
- Safe simplifications are covered by Tasks 1, 2, 3, 4, and 5.
|
||||||
|
- Built-in replacement opportunities are covered by Tasks 2 and 5.
|
||||||
|
- Fail-safe gaps are covered by Tasks 3, 4, 6, and 7.
|
||||||
|
- Rust, Flutter, and Android verification are covered by Tasks 1 and 7, with focused verification in Tasks 2 through 4.
|
||||||
|
- Documentation updates are covered by Tasks 4, 6, and 7.
|
||||||
|
- Small commit policy is covered by every task's dedicated commit step.
|
||||||
|
|
||||||
|
Placeholder scan: The plan contains no open placeholders. Task 5 uses `git status --short` before staging because the exact second-pass files are only known after the search runs; the example command shows the required staging style.
|
||||||
|
|
||||||
|
Type consistency: The plan does not introduce new APIs or types. It preserves current crate and file boundaries from the approved design.
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
# Maintainability Continuation Design
|
||||||
|
|
||||||
|
**Date:** 2026-06-08
|
||||||
|
**Status:** Approved design for implementation and full code-review remediation; Task 0 and focused audio-realtime fixes landed, documentation/governance alignment in progress
|
||||||
|
**Scope:** Continue the current working-branch maintainability pass, add full code-review findings, and fix high-risk bugs before broad rewrites.
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
This design continues the project review already present in the working tree. The goal is to simplify the project where changes are low-risk, testable, and documented, while avoiding speculative architecture churn.
|
||||||
|
|
||||||
|
The work covers unnecessary functions, structs, files, modules, duplicated custom implementations, built-in replacement opportunities, outdated documents, fail-safe gaps, Android runtime verification requirements, and full code-review remediation for hidden bugs.
|
||||||
|
|
||||||
|
## Recommended Approach
|
||||||
|
|
||||||
|
Use a targeted continuation of the current maintainability pass, now ordered by safety risk.
|
||||||
|
|
||||||
|
The existing branch already contains a first slice of simplification: core event DTO extraction, network diagnostics locality, `VecDeque` queue improvements, derived PTT backend errors, render downmix helper reuse, state reducer reuse, workspace metadata cleanup, and documentation updates. This design treats those changes as the baseline, but the full review found privacy, realtime-audio, disconnect, and documentation-governance issues that take priority over cosmetic simplification.
|
||||||
|
|
||||||
|
The remediation order is:
|
||||||
|
|
||||||
|
- Privacy and stuck-transmit fail-safes in Flutter voice state. Fixed in commit `d835394`.
|
||||||
|
- iOS audio-session error hardening before Rust VoiceProcessingIO startup. Missing-plugin/error handling fixed in commit `d835394`; iOS device runtime verification remains required.
|
||||||
|
- Rust realtime audio safety, especially unsynchronized render-reference buffers and blocking/allocating callbacks. Focused callback-path hardening fixed in commit `8606eb4`; full lock-free `AudioHandler` / config / debug-recorder redesign remains a follow-up.
|
||||||
|
- Bounded disconnect/control-plane progress in Rust protocol/core. Pending unless later code-review evidence closes it.
|
||||||
|
- Documentation and release/governance contradictions that can cause wrong verification claims. Addressed by Task 0.2 documentation alignment.
|
||||||
|
- Larger Module splits after behavior is protected by tests.
|
||||||
|
|
||||||
|
Rejected alternatives:
|
||||||
|
|
||||||
|
- Documentation-only audit: safer, but leaves clear simplifications unimplemented.
|
||||||
|
- Broad architectural cleanup: may produce long-term wins, but is too risky for this pass because bridge, protocol, audio, and Android behavior have high regression cost.
|
||||||
|
|
||||||
|
## Architecture Boundaries
|
||||||
|
|
||||||
|
The existing responsibilities remain intact:
|
||||||
|
|
||||||
|
- Flutter owns presentation, navigation, Material 3 behavior, accessibility, localization presentation, and platform UI behavior.
|
||||||
|
- Flutter Rust Bridge owns typed DTO/API glue and generated bindings.
|
||||||
|
- Rust Core owns session orchestration, cross-crate coordination, bridge-facing public events, and stable public APIs.
|
||||||
|
- Protocol owns TeamSpeak-compatible protocol isolation behind `tsclientlib`.
|
||||||
|
- Audio owns capture, render, processing, PTT backends, platform audio behavior, and voice packet handling where explicitly documented.
|
||||||
|
- Diagnostics owns redaction, logs, export records, and bounded diagnostic history.
|
||||||
|
|
||||||
|
Public interfaces should stay stable unless a change clearly removes duplicated or unnecessary code and has direct verification.
|
||||||
|
|
||||||
|
## Review Targets
|
||||||
|
|
||||||
|
The implementation review should inspect these areas first:
|
||||||
|
|
||||||
|
- `core/chanora_core/src/lib.rs`, `events.rs`, `network_diagnostics.rs`, and `ptt.rs`
|
||||||
|
- `crates/chanora_audio`, especially duplicated render, capture, PTT, and platform-audio helpers
|
||||||
|
- `crates/chanora_state` reducer paths
|
||||||
|
- `crates/chanora_protocol` adapter ordering, event, and DTO mapping paths
|
||||||
|
- `crates/chanora_bridge/src/api.rs`, excluding generated bridge files unless regeneration is intentionally part of a change
|
||||||
|
- `crates/chanora_diagnostics/src/lib.rs`
|
||||||
|
- `crates/chanora_prefetch` and `crates/chanora_resolver` as a documented follow-up seam decision unless a trivial cleanup appears
|
||||||
|
- `apps/chanora_flutter/lib`, excluding generated localization and bridge files unless an API change requires updates
|
||||||
|
- governance, architecture, implementation-status, and verification documents affected by the code review
|
||||||
|
|
||||||
|
Full code-review remediation targets:
|
||||||
|
|
||||||
|
- `apps/chanora_flutter/lib/main.dart`: mute ownership, iOS audio-session preflight, chat/unread follow-ups, and oversized session-controller extraction candidates.
|
||||||
|
- `apps/chanora_flutter/lib/widgets/voice_compact.dart`: touch PTT release-on-dispose fail-safe.
|
||||||
|
- `apps/chanora_flutter/lib/services/ios_audio_session_controller.dart`: missing-plugin fail-safe handling.
|
||||||
|
- `apps/chanora_flutter/lib/services/audio_lifecycle_service.dart`: macOS route/default-device no-op documentation or future adapter seam.
|
||||||
|
- `crates/chanora_audio/src/android_voice_unit.rs`, `ios_raw_unit.rs`, `ios_voice_unit.rs`, and `engine.rs`: realtime callback safety and platform lifecycle rollback.
|
||||||
|
- `core/chanora_core/src/lib.rs` and `crates/chanora_protocol/src/adapter.rs`: bounded disconnect and control-plane progress under voice load.
|
||||||
|
- `README.md`, `CHANGELOG.md`, `docs/release/*`, `docs/verification/*`, and `docs/governance/product-decision-register.md`: stale platform, release, VAD, Android runtime, and decision-register claims.
|
||||||
|
|
||||||
|
## Simplification Rules
|
||||||
|
|
||||||
|
Every code change must satisfy these rules:
|
||||||
|
|
||||||
|
- Prefer deletion, built-in APIs, derives, or reuse of existing helpers over new abstractions.
|
||||||
|
- Merge files or modules only when the merged unit has a clearer single responsibility.
|
||||||
|
- Split files only when it improves locality around a stable responsibility and preserves public API shape.
|
||||||
|
- Do not manually edit generated files unless the generation process is part of the verified change.
|
||||||
|
- Do not introduce backward-compatibility shims unless there is a persisted-data, shipped-API, external-consumer, or explicit product need.
|
||||||
|
- Record larger architectural opportunities in the maintainability review instead of forcing them into this pass.
|
||||||
|
|
||||||
|
Full code-review fix rules:
|
||||||
|
|
||||||
|
- Fix safety bugs before Module split work.
|
||||||
|
- Use test-driven development for production behavior changes: write the failing test, run it, implement the minimal fix, then rerun the test.
|
||||||
|
- Keep manual/generated bridge files out of direct edits unless regeneration is intentionally verified.
|
||||||
|
- Split huge Modules only when the split creates a deeper Module with leverage and locality; file-size-only sharding is not sufficient.
|
||||||
|
- Compare architecture choices against established voice/chat client practice: Mumble-style bounded voice/control separation, Discord/TeamSpeak-style independent mute owners, WebRTC-style realtime callback minimalism, and Matrix/Element-style coherent state replication.
|
||||||
|
|
||||||
|
## Testing Design
|
||||||
|
|
||||||
|
Verification is tied to change type:
|
||||||
|
|
||||||
|
- Rust-only changes require `cargo fmt --all`, `cargo check --workspace`, and `cargo test --workspace`.
|
||||||
|
- Flutter changes require `flutter analyze` and `flutter test --exclude-tags e2e` from `apps/chanora_flutter`.
|
||||||
|
- Bridge DTO/API changes require Rust verification, bridge generation check, Flutter analyze, and Flutter tests.
|
||||||
|
- Android platform, permission, lifecycle, or audio changes require Rust and Flutter verification plus Android NDK target compilation, `adb devices -l`, Android build/install, and a device or emulator smoke test.
|
||||||
|
- Documentation-only changes require affected docs and cross-links to be read and checked; code tests are not required unless the docs describe a code change just made.
|
||||||
|
|
||||||
|
If no ADB target is connected, Android runtime verification must be recorded as blocked. If Android target compilation cannot find the NDK compiler, for example `aarch64-linux-android-clang`, Android build evidence must also be recorded as blocked. The implementation must not claim Android runtime success without build/install/smoke evidence from an authorized device or emulator.
|
||||||
|
|
||||||
|
## Fail-Safe Review
|
||||||
|
|
||||||
|
The review must identify fail-safe gaps and either verify them, fix them, or record the missing evidence.
|
||||||
|
|
||||||
|
Priority fail-safe areas:
|
||||||
|
|
||||||
|
- User mute ownership must not be cleared by talk-power or permission recovery.
|
||||||
|
- Touch and keyboard PTT must release on cancellation, disposal, disconnect, lifecycle transition, or missed-up conditions.
|
||||||
|
- iOS AVAudioSession must be configured and activated before VoiceProcessingIO startup.
|
||||||
|
- Realtime callbacks must not block, allocate repeatedly, or use unsynchronized mutable aliasing.
|
||||||
|
- Disconnect and control requests must be bounded and must not hold global session locks across unbounded transport waits.
|
||||||
|
- Android and iOS device runtime behavior must be verified on hardware or an authorized emulator/simulator where applicable before platform success is claimed.
|
||||||
|
- Android secure storage and Keystore-backed data-encryption-key handling
|
||||||
|
- Android permission and audio lifecycle behavior
|
||||||
|
- Stuck PTT prevention and missed-key-up recovery
|
||||||
|
- Diagnostic redaction and privacy-sensitive event export
|
||||||
|
- Bridge DTO drift between Core, Bridge, and Dart generated bindings
|
||||||
|
- Protocol isolation exceptions for voice packet handling
|
||||||
|
- Runtime behavior gaps not covered by unit tests
|
||||||
|
|
||||||
|
No release-readiness or production-safety claim should be made without matching evidence.
|
||||||
|
|
||||||
|
## Documentation Design
|
||||||
|
|
||||||
|
The working review record remains `docs/governance/maintainability-review-2026-06-08.md`.
|
||||||
|
|
||||||
|
Documents to update when affected:
|
||||||
|
|
||||||
|
- `README.md`
|
||||||
|
- `CHANGELOG.md`
|
||||||
|
- `docs/governance/document-index.md`
|
||||||
|
- `docs/governance/product-decision-register.md`
|
||||||
|
- `docs/architecture/sad.md`
|
||||||
|
- `docs/architecture/sdd.md`
|
||||||
|
- `docs/implementation-status-2026-05-28.md`
|
||||||
|
- `docs/verification/swe4-unit-verification-plan.md`
|
||||||
|
- `docs/verification/swe5-software-integration-verification-plan.md`
|
||||||
|
- `docs/verification/verification-master-plan.md`
|
||||||
|
- `docs/verification/sys4-system-integration-verification-plan.md`
|
||||||
|
- `docs/release/release-readiness-go-nogo-record.md`
|
||||||
|
- `docs/release/dv-waiver-register.md`
|
||||||
|
- release or fail-safe records if verification status changes
|
||||||
|
|
||||||
|
Documentation should distinguish completed changes, follow-up opportunities, blocked verification, and release limitations.
|
||||||
|
|
||||||
|
## Commit Policy
|
||||||
|
|
||||||
|
No commit is created automatically. A commit happens only when explicitly requested, after inspecting `git status`, `git diff`, and recent commits.
|
||||||
|
|
||||||
|
## Success Criteria
|
||||||
|
|
||||||
|
This work is successful when:
|
||||||
|
|
||||||
|
- Safe simplifications are implemented or recorded as follow-up opportunities.
|
||||||
|
- Built-in replacement opportunities are applied only when behavior remains covered by tests.
|
||||||
|
- Fail-safe gaps are documented with required evidence or fixed with verification.
|
||||||
|
- Rust and Flutter verification are run as required by the touched files, including targeted regression tests for every fixed bug.
|
||||||
|
- Android ADB runtime verification is run when a target is available or explicitly recorded as blocked.
|
||||||
|
- Documents reflect the final code and verification state.
|
||||||
|
- Full code-review findings are either fixed, downgraded with evidence, or recorded as follow-up risks with verification requirements.
|
||||||
@@ -0,0 +1,176 @@
|
|||||||
|
# Poke Without Message Design
|
||||||
|
|
||||||
|
**Date:** 2026-06-09
|
||||||
|
**Status:** Approved design for implementation
|
||||||
|
**Scope:** Allow intentional TeamSpeak-compatible pokes without message text while preserving empty-message blocking for normal chat targets.
|
||||||
|
|
||||||
|
## 1. Goal
|
||||||
|
|
||||||
|
Chanora should let a user poke another connected client without typing a message. A poke is an attention event, not an empty chat message. The UI should make that distinction explicit so the empty state is intentional, understandable, and safe from accidental spam.
|
||||||
|
|
||||||
|
The implementation target is narrow:
|
||||||
|
|
||||||
|
- Sending a poke with an empty message is allowed.
|
||||||
|
- Sending an empty normal chat message remains blocked.
|
||||||
|
- Incoming and historical empty pokes continue to render as poke events, not blank chat bubbles.
|
||||||
|
- Existing poke notification behavior remains compatible with message and no-message pokes.
|
||||||
|
|
||||||
|
## 2. Research Summary
|
||||||
|
|
||||||
|
TeamSpeak-compatible poke behavior is command-like: the ServerQuery shape is `clientpoke clid={clientID} msg={text}`, backed by poke permissions such as `i_client_poke_power` and `i_client_needed_poke_power`. The product semantics are closer to an attention nudge than to a private text message.
|
||||||
|
|
||||||
|
Client behavior and community expectations point to two UX risks:
|
||||||
|
|
||||||
|
- The action can be useful without text because the sender often only wants attention.
|
||||||
|
- The action can be abused as interruption spam, so the UI must keep the action deliberate and preserve existing receiver-side suppression and notification preferences.
|
||||||
|
|
||||||
|
The approved product direction is therefore to model no-message poke as a first-class attention event with optional text, rather than as an exception in the normal chat composer.
|
||||||
|
|
||||||
|
## 3. Recommended UX
|
||||||
|
|
||||||
|
Poke uses a poke-specific sending surface. The surface may reuse the current chat detail implementation internally, but the user-facing copy and validation must make the target type clear.
|
||||||
|
|
||||||
|
Required poke-target behavior:
|
||||||
|
|
||||||
|
| Element | Behavior |
|
||||||
|
|---|---|
|
||||||
|
| Header | Shows that the current surface is for poking the selected user. |
|
||||||
|
| Text field | Optional message input. Placeholder should communicate that the message is optional. |
|
||||||
|
| Primary action | Label is `Poke`, not `Send`. Enabled even when the trimmed message is empty. |
|
||||||
|
| Empty send | Sends an intentional poke with `message: ''`. |
|
||||||
|
| Non-empty send | Sends a poke with the typed message. |
|
||||||
|
| History row | Empty poke renders as an attention event such as `Alice poked you`, never as a blank message. |
|
||||||
|
|
||||||
|
Required non-poke chat behavior:
|
||||||
|
|
||||||
|
| Target | Empty text behavior |
|
||||||
|
|---|---|
|
||||||
|
| Channel chat | Block send. |
|
||||||
|
| Server chat | Block send. |
|
||||||
|
| Private chat | Block send. |
|
||||||
|
| Any future text-chat target | Block send unless it is explicitly modeled as a poke-like attention event. |
|
||||||
|
|
||||||
|
## 4. Architecture Boundaries
|
||||||
|
|
||||||
|
The change should stay inside the existing UI and bridge boundaries:
|
||||||
|
|
||||||
|
- Flutter owns presentation, composer validation, button enablement, localization copy, and widget tests.
|
||||||
|
- Flutter Rust Bridge continues to pass typed `BridgeMessageTarget` and message text across the bridge.
|
||||||
|
- Rust Core and Protocol continue to route `MessageTarget::Poke(client_id)` through the existing poke send path.
|
||||||
|
- Protocol remains the only layer that knows how `tsclientlib` sends a TeamSpeak-compatible poke.
|
||||||
|
|
||||||
|
No new protocol concept is required. The existing bridge/protocol model already has `BridgeMessageTarget.poke` / `MessageTarget::Poke(u64)` and `client.poke(message)`. The key design change is target-aware composer validation in Flutter.
|
||||||
|
|
||||||
|
## 5. Implementation Design
|
||||||
|
|
||||||
|
The implementation should use a target-aware send policy.
|
||||||
|
|
||||||
|
For `BridgeMessageTarget.poke`:
|
||||||
|
|
||||||
|
- Do not reject an empty trimmed input.
|
||||||
|
- Send the original or trimmed message according to the existing chat composer convention. If the current send path trims normal messages before sending, apply the same text normalization before passing the poke message.
|
||||||
|
- Clear the composer after successful send, including empty-poke sends.
|
||||||
|
- Preserve existing error and snackbar behavior for failed sends.
|
||||||
|
|
||||||
|
For all other `BridgeMessageTarget` variants:
|
||||||
|
|
||||||
|
- Keep the existing empty-trimmed-text guard.
|
||||||
|
- Keep current button enablement and keyboard submit behavior unless those paths need target-aware adjustment to preserve the same empty-message block.
|
||||||
|
|
||||||
|
A simple policy helper is preferred over scattered conditionals. Example shape:
|
||||||
|
|
||||||
|
```dart
|
||||||
|
bool canSendMessage({
|
||||||
|
required BridgeMessageTarget target,
|
||||||
|
required String text,
|
||||||
|
}) {
|
||||||
|
if (target is BridgeMessageTarget_Poke) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return text.trim().isNotEmpty;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The exact Dart type checks should follow the generated bridge type names used in the current codebase.
|
||||||
|
|
||||||
|
## 6. Notification And History Behavior
|
||||||
|
|
||||||
|
Existing no-message receiving behavior should remain the reference behavior:
|
||||||
|
|
||||||
|
- Incoming empty poke notification body falls back to text equivalent to `Alice pokes you`.
|
||||||
|
- Incoming poke with message includes the message in the notification body.
|
||||||
|
- Active-chat suppression and muted-sender preferences continue to apply.
|
||||||
|
- Poke history rows distinguish poke events from normal chat rows.
|
||||||
|
|
||||||
|
The send-side change must not introduce a new blank message row shape. If the sender's local history records sent pokes, empty poke history should render as a poke action line with no empty bubble.
|
||||||
|
|
||||||
|
## 7. Abuse And Safety Rules
|
||||||
|
|
||||||
|
This slice does not add new anti-spam controls. It relies on existing TeamSpeak-compatible permissions, inbound poke strength/rate suppression, notification preferences, active-chat suppression, and muted sender handling.
|
||||||
|
|
||||||
|
The implementation must not weaken any existing receiver-side controls. If testing reveals that empty sent pokes bypass suppression, notification preferences, or history classification, that is a bug to fix in the same implementation pass.
|
||||||
|
|
||||||
|
Future follow-ups, not part of this slice:
|
||||||
|
|
||||||
|
- Per-sender or per-server outbound poke cooldown UI.
|
||||||
|
- Receiver-side "never show poke dialog" equivalent beyond current notification preferences.
|
||||||
|
- Dedicated poke inbox or grouped poke history.
|
||||||
|
|
||||||
|
## 8. Files Expected To Change
|
||||||
|
|
||||||
|
Expected implementation targets:
|
||||||
|
|
||||||
|
| File | Expected change |
|
||||||
|
|---|---|
|
||||||
|
| `apps/chanora_flutter/lib/widgets/chat_views.dart` | Make composer validation and action enablement target-aware for poke. Update poke placeholder/action copy if needed. |
|
||||||
|
| `apps/chanora_flutter/test/widgets/chat_views_test.dart` | Add widget coverage for empty poke send and normal empty chat blocking. |
|
||||||
|
|
||||||
|
Optional targets if the implementation exposes missing copy or routing seams:
|
||||||
|
|
||||||
|
| File | Possible change |
|
||||||
|
|---|---|
|
||||||
|
| `apps/chanora_flutter/lib/main.dart` | Only if opening a poke target needs a clearer poke-specific title or route configuration. |
|
||||||
|
| `apps/chanora_flutter/lib/l10n/*.arb` | Only if current copy cannot express optional poke messages without hard-coded strings. |
|
||||||
|
| `apps/chanora_flutter/test/services/poke_notification_service_test.dart` | Only if send-side changes affect notification payload assumptions. |
|
||||||
|
|
||||||
|
The Rust protocol path should not need behavior changes unless tests prove that empty strings are blocked below Flutter.
|
||||||
|
|
||||||
|
## 9. Test Design
|
||||||
|
|
||||||
|
Required tests:
|
||||||
|
|
||||||
|
- Poke target shows an enabled primary `Poke` action when the text field is empty.
|
||||||
|
- Tapping `Poke` on an empty poke target calls the send callback with `BridgeMessageTarget.poke` and an empty message.
|
||||||
|
- Poke target still sends a typed message when text is present.
|
||||||
|
- Normal channel/server/private chat targets keep blocking empty sends.
|
||||||
|
- Empty poke history renders as a poke event line, not an empty text bubble.
|
||||||
|
|
||||||
|
Useful regression checks if already easy to target:
|
||||||
|
|
||||||
|
- Keyboard submit follows the same target-aware validation as the button.
|
||||||
|
- Failed empty-poke send keeps existing error presentation.
|
||||||
|
- Incoming empty poke notification tests still pass unchanged.
|
||||||
|
|
||||||
|
## 10. Validation
|
||||||
|
|
||||||
|
For the implementation branch, run focused Flutter verification first:
|
||||||
|
|
||||||
|
```text
|
||||||
|
flutter test test/widgets/chat_views_test.dart
|
||||||
|
flutter test test/services/poke_notification_service_test.dart
|
||||||
|
flutter test test/services/poke_active_chat_test.dart
|
||||||
|
flutter analyze
|
||||||
|
```
|
||||||
|
|
||||||
|
If Rust or bridge files are touched, also run the matching Rust and bridge checks for the touched layer. Documentation-only changes require reading the affected spec and checking the diff; code tests are not required for this design commit.
|
||||||
|
|
||||||
|
## 11. Success Criteria
|
||||||
|
|
||||||
|
This design is implemented successfully when:
|
||||||
|
|
||||||
|
- A user can send a poke with no typed message.
|
||||||
|
- Normal chat targets still reject empty sends.
|
||||||
|
- The poke composer communicates that message text is optional.
|
||||||
|
- Empty pokes are represented as poke events in history and notifications.
|
||||||
|
- Existing poke notification preferences and suppression behavior remain intact.
|
||||||
|
- Focused widget/service tests and `flutter analyze` pass, or any unrelated pre-existing failure is named with evidence.
|
||||||
+1
-1
@@ -1353,7 +1353,7 @@ External Server -> Protocol Adapter -> Rust Core -> State Engine -> Bridge -> Fl
|
|||||||
| Protocol compatibility | SE-05, SE-11 | `tsclientlib` adapter, protocol probe, compatibility matrix |
|
| Protocol compatibility | SE-05, SE-11 | `tsclientlib` adapter, protocol probe, compatibility matrix |
|
||||||
| Secure identity handling | SE-03, SE-16 | Platform secure storage, no plaintext private keys |
|
| Secure identity handling | SE-03, SE-16 | Platform secure storage, no plaintext private keys |
|
||||||
| Diagnostic privacy | SE-17, SE-19 | Redaction before export, user-initiated export |
|
| Diagnostic privacy | SE-17, SE-19 | Redaction before export, user-initiated export |
|
||||||
| Mobile foreground voice | SE-03, SE-14 | AVAudioSession, Android foreground service, lifecycle handling |
|
| Mobile foreground voice | SE-03, SE-14 | AVAudioSession (call-scoped VoIP activation with `.mixWithOthers`, idle `.ambient` baseline), Android foreground service, lifecycle handling |
|
||||||
| Release trust | SE-18 | Signing, notarization, app-store release metadata review |
|
| Release trust | SE-18 | Signing, notarization, app-store release metadata review |
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -32,6 +32,8 @@ This plan defines unit verification coverage for the software modules that imple
|
|||||||
| `flutter analyze` in `apps/chanora_flutter` | Software | Dart static analysis |
|
| `flutter analyze` in `apps/chanora_flutter` | Software | Dart static analysis |
|
||||||
| `flutter test --exclude-tags e2e` in `apps/chanora_flutter` | Software / QA | Flutter unit and widget tests |
|
| `flutter test --exclude-tags e2e` in `apps/chanora_flutter` | Software / QA | Flutter unit and widget tests |
|
||||||
|
|
||||||
|
For local code-change reviews, a Rust change is not complete until `cargo fmt --all`, `cargo check --workspace`, and `cargo test --workspace` have been run fresh and read for failures. Flutter or bridge changes additionally require Flutter analysis/tests.
|
||||||
|
|
||||||
## 4. SRS Unit Coverage Focus
|
## 4. SRS Unit Coverage Focus
|
||||||
|
|
||||||
| SRS group | Unit focus | Status for DV |
|
| SRS group | Unit focus | Status for DV |
|
||||||
@@ -51,6 +53,7 @@ This plan defines unit verification coverage for the software modules that imple
|
|||||||
| Event replay tool not found | P1 verification hook is incomplete | Mark deferred/P1 in SWE.6 and waiver register if discussed |
|
| Event replay tool not found | P1 verification hook is incomplete | Mark deferred/P1 in SWE.6 and waiver register if discussed |
|
||||||
| Platform keyring behavior not exercised in CI | Secure-storage unit evidence is incomplete for real OS services | Cover through platform audit/SYS.4, not CI-only claims |
|
| Platform keyring behavior not exercised in CI | Secure-storage unit evidence is incomplete for real OS services | Cover through platform audit/SYS.4, not CI-only claims |
|
||||||
| Audio device hot-plug recovery follow-up exists | Limits reliability evidence for SRS-082 | Mark as P1 gap |
|
| Audio device hot-plug recovery follow-up exists | Limits reliability evidence for SRS-082 | Mark as P1 gap |
|
||||||
|
| Android runtime smoke cannot run without attached target | Rust unit tests do not exercise Android permission/audio/lifecycle fail-safes | `adb devices -l` must show a device or emulator before Android runtime success can be claimed |
|
||||||
|
|
||||||
## 6. SWE.4 DV Decision Rule
|
## 6. SWE.4 DV Decision Rule
|
||||||
|
|
||||||
|
|||||||
@@ -52,7 +52,20 @@ An integration path passes when:
|
|||||||
| iOS build is unsigned | Blocks App Store/TestFlight release approval | Release record must state unsigned verification only |
|
| iOS build is unsigned | Blocks App Store/TestFlight release approval | Release record must state unsigned verification only |
|
||||||
| Android Keystore-backed DEK deferred | Limits secure-storage integration claim on Android | Waiver required for internal testing; public release claim blocked |
|
| Android Keystore-backed DEK deferred | Limits secure-storage integration claim on Android | Waiver required for internal testing; public release claim blocked |
|
||||||
| Event replay infrastructure not found | Limits protocol-state integration stress evidence | Mark P1 gap |
|
| Event replay infrastructure not found | Limits protocol-state integration stress evidence | Mark P1 gap |
|
||||||
|
| Android target compile/runtime blocked during local review | Missing NDK compiler `aarch64-linux-android-clang` and no attached authorized target block device/emulator verification of Android audio, permission, lifecycle, and storage fail-safe behaviour | Fix/install the NDK toolchain, connect a device/emulator, confirm with `adb devices -l`, then run Android build/install/smoke before claiming Android success |
|
||||||
|
|
||||||
## 6. SWE.5 DV Decision Rule
|
## 6. Android Runtime Verification Gate
|
||||||
|
|
||||||
|
Android integration changes require a working Android target toolchain and a connected device or emulator. The minimum local gate is:
|
||||||
|
|
||||||
|
1. The Android NDK compiler needed by the target build, including `aarch64-linux-android-clang` for arm64, is available.
|
||||||
|
2. `adb devices -l` shows one authorized target.
|
||||||
|
3. Android app builds for that target.
|
||||||
|
4. The app installs and launches.
|
||||||
|
5. Permission, connect-screen, audio-start/stop, and diagnostic-export smoke paths are exercised or explicitly marked not applicable to the change.
|
||||||
|
|
||||||
|
If the NDK compiler is unavailable or no target is connected and authorized, Android verification is blocked rather than passed.
|
||||||
|
|
||||||
|
## 7. SWE.5 DV Decision Rule
|
||||||
|
|
||||||
SWE.5 can pass for DV documentation readiness if every integration path has an identified evidence method and every incomplete path has a waiver or follow-up. SWE.5 does not pass for public release until current candidate integration runs are attached to the release-readiness record.
|
SWE.5 can pass for DV documentation readiness if every integration path has an identified evidence method and every incomplete path has a waiver or follow-up. SWE.5 does not pass for public release until current candidate integration runs are attached to the release-readiness record.
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ This plan verifies Chanora as an integrated application system, including softwa
|
|||||||
|
|
||||||
| Platform | Current candidate status | Required SYS.4 evidence before public/store release |
|
| Platform | Current candidate status | Required SYS.4 evidence before public/store release |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| Android | Implemented features include permissions, foreground service, Oboe audio, MODE_IN_COMMUNICATION; Android Keystore-backed DEK deferred | Device smoke, permission flow, foreground voice, secure-storage limitation waiver, Play target SDK inspection |
|
| Android | Implemented features include permissions, foreground service, Oboe audio, MODE_IN_COMMUNICATION; Android Keystore-backed DEK deferred; local target compile/runtime smoke blocked by missing `aarch64-linux-android-clang` and no authorized ADB target | NDK target compilation, `adb devices -l` authorized target evidence, device smoke, permission flow, foreground voice, secure-storage limitation waiver, Play target SDK inspection |
|
||||||
| iOS | Source-buildable and unsigned; AVAudioSession work exists; public artifact not ready | Unsigned build evidence, device audio session smoke, signing/TestFlight evidence before release |
|
| iOS | Source-buildable and unsigned; AVAudioSession work exists; public artifact not ready | Unsigned build evidence, device audio session smoke, signing/TestFlight evidence before release |
|
||||||
| Windows | Source-buildable; smoke procedure exists | Executed smoke result, PTT capability evidence, packaging/signing evidence before release |
|
| Windows | Source-buildable; smoke procedure exists | Executed smoke result, PTT capability evidence, packaging/signing evidence before release |
|
||||||
| macOS | Source-buildable; not in current release artifacts | Build evidence, PTT capability evidence, signing/notarization evidence before release |
|
| macOS | Source-buildable; not in current release artifacts | Build evidence, PTT capability evidence, signing/notarization evidence before release |
|
||||||
@@ -51,6 +51,7 @@ This plan verifies Chanora as an integrated application system, including softwa
|
|||||||
| Security/privacy/legal readiness | DEC-012 and privacy/security gates are signed off or explicitly block release |
|
| Security/privacy/legal readiness | DEC-012 and privacy/security gates are signed off or explicitly block release |
|
||||||
| Release artifact integrity | Build number, commit SHA, tag, artifact hashes, and signing status are recorded |
|
| Release artifact integrity | Build number, commit SHA, tag, artifact hashes, and signing status are recorded |
|
||||||
| Environmental limitations | Source-build-only, unsigned, or platform-specific limitations are listed in release readiness |
|
| Environmental limitations | Source-build-only, unsigned, or platform-specific limitations are listed in release readiness |
|
||||||
|
| Android runtime claim control | No Android runtime, permission-flow, or audio-lifecycle success is claimed until target compile, install, and device/emulator smoke evidence are attached |
|
||||||
|
|
||||||
## 6. SYS.4 DV Decision Rule
|
## 6. SYS.4 DV Decision Rule
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
**Document status:** DV meeting baseline candidate
|
**Document status:** DV meeting baseline candidate
|
||||||
**Date:** 2026-05-29
|
**Date:** 2026-05-29
|
||||||
**Applies to:** Chanora `0.2.0-beta.1` workspace / `v1.0.0-rc.1` release-candidate evidence
|
**Applies to:** Chanora Rust workspace `0.2.0-beta.1`, Flutter app `0.3.0+100`, and current DV/release-candidate evidence
|
||||||
**Primary upstream documents:** `docs/sysrs.md`, `docs/sysdes.md`, `docs/srs.md`, `docs/architecture/sad.md`, `docs/architecture/sdd.md`, `docs/implementation-status-2026-05-28.md`
|
**Primary upstream documents:** `docs/sysrs.md`, `docs/sysdes.md`, `docs/srs.md`, `docs/architecture/sad.md`, `docs/architecture/sdd.md`, `docs/implementation-status-2026-05-28.md`
|
||||||
|
|
||||||
## 1. Purpose
|
## 1. Purpose
|
||||||
@@ -79,6 +79,7 @@ The DV meeting can pass the documentation baseline if reviewers agree that:
|
|||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| DEC-012 legal/trademark/OSS sign-off | Blocked / open | Blocks public or store release | `docs/legal/trademark-and-attribution-review.md`, `docs/release/dv-waiver-register.md` |
|
| DEC-012 legal/trademark/OSS sign-off | Blocked / open | Blocks public or store release | `docs/legal/trademark-and-attribution-review.md`, `docs/release/dv-waiver-register.md` |
|
||||||
| Android Keystore-backed DEK | Deferred to v1.1 | Blocks claim that Android secrets use hardware-backed DEK protection | `docs/release/dv-waiver-register.md` |
|
| Android Keystore-backed DEK | Deferred to v1.1 | Blocks claim that Android secrets use hardware-backed DEK protection | `docs/release/dv-waiver-register.md` |
|
||||||
|
| Android target compilation and runtime smoke | Blocked locally | Blocks Android runtime, permission-flow, and audio-lifecycle success claims until NDK compiler and authorized target evidence exist | `docs/governance/maintainability-review-2026-06-08.md`, `docs/release/dv-waiver-register.md` |
|
||||||
| Full state reducer test suite | Partial | Blocks full claim for SysRS-235/SysDes-103/SRS-059 through SRS-061 | `docs/release/dv-waiver-register.md` |
|
| Full state reducer test suite | Partial | Blocks full claim for SysRS-235/SysDes-103/SRS-059 through SRS-061 | `docs/release/dv-waiver-register.md` |
|
||||||
| Desktop and iOS release artifacts | Source-buildable / unsigned only | Blocks broad binary distribution claims | `docs/release/release-readiness-go-nogo-record.md` |
|
| Desktop and iOS release artifacts | Source-buildable / unsigned only | Blocks broad binary distribution claims | `docs/release/release-readiness-go-nogo-record.md` |
|
||||||
| Store signing and notarization | Not complete for candidate | Blocks production release | `docs/release/release-readiness-go-nogo-record.md` |
|
| Store signing and notarization | Not complete for candidate | Blocks production release | `docs/release/release-readiness-go-nogo-record.md` |
|
||||||
|
|||||||
Reference in New Issue
Block a user