feat: Android Oboe voice backend — WebRTC APM, VAD, HW/SW toggle, BBCode welcome, link trust, foreground task

Audio engine (Rust):
- Android Oboe: WebRTC APM (AEC/NS/AGC/HPF) + TEN/Silero ONNX VAD
- Hardware effects (JNI) with software fallback per-effect
- Render reference buffer for AEC between output/capture callbacks
- Voice activity gate: suppress transmission when speaker muted (all platforms)
- Audio focus (SDD-109) + Bluetooth SCO (SDD-110) via JNI
- ONNX Runtime 1.26 via ort 2.0.0-rc.12 (down from rc.10, ndarray 0.17)
- VAD worker channel capacity 8→32, initial seq u64::MAX (warm-up fix)
- TEN VAD default backend (was Silero)
- Platform→WebrtcApm resolution after hardware binding
- oboe-rs edisonjwa fork with get_raw_session_id()

Android Kotlin:
- AndroidAudioFocusController + AndroidBluetoothScoController
- AndroidAudioLifecycleController (route changes to Flutter)
- ProGuard rules for new controllers

Flutter UI:
- VoiceSettings: Android HW/SW toggle (Platform auto / WebRTC APM)
- VoiceStatusChip: mute warning border + Speaker muted label
- BBCode welcome message parser (BbCodeText, case-insensitive)
- Welcome message foldable (expanded by default)
- Link trust dialog (domain wildcards, SharedPreferences)
- HapticFeedback on voice sheet opener
- Server name in AppBar, version v0.1.0
- Default channel (id=1) visible, serverquery clients hidden
- flutter_foreground_task integration

Config:
- ort load-dynamic on all non-iOS (Android/Linux/Windows)
- ONNX Runtime AAR 1.26.0
- ndarray moved to common deps (was Apple-only)
This commit is contained in:
Edison Jwa
2026-05-22 09:29:57 +09:00
parent 6af4ecab0f
commit bf284018e6
37 changed files with 3676 additions and 703 deletions
+134 -17
View File
@@ -18,6 +18,7 @@ import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:package_info_plus/package_info_plus.dart';
import 'package:path_provider/path_provider.dart';
import 'package:flutter_foreground_task/flutter_foreground_task.dart';
import 'l10n/generated/app_localizations.dart';
import 'services/android_permissions_service.dart';
@@ -30,6 +31,8 @@ import 'widgets/voice_platform.dart';
import 'widgets/voice_bar.dart';
import 'widgets/voice_compact.dart';
import 'widgets/voice_settings.dart';
import 'widgets/bbcode_text.dart';
import 'services/link_trust_service.dart';
bool get _isMacOS => !kIsWeb && Platform.isMacOS;
@@ -62,11 +65,12 @@ Future<void> _configureBundledVadModels() async {
assetPath: _sileroVadAsset,
fileName: 'silero_vad.onnx',
);
await _copyBundledAssetToDocuments(
final ten = await _copyBundledAssetToDocuments(
assetPath: _tenVadAsset,
fileName: 'ten_vad.onnx',
);
await rust.setVadModelPath(path: silero.path);
await rust.setTenVadModelPath(path: ten.path);
}
/// Top padding for macOS to clear traffic-light buttons.
@@ -136,7 +140,7 @@ String? _pttDisplayLabelForKey(LogicalKeyboardKey k) {
/// pubspec.yaml advances (e.g. rc.8 -> rc.9 -> 1.0.0). The
/// build-counter suffix changes automatically on every pubspec
/// `+<n>` bump because Flutter writes it into Info.plist.
const String _kSemverBaseline = 'v1.0.0-rc.8';
const String _kSemverBaseline = 'v0.1.0';
String _kAppVersion = _kSemverBaseline;
Future<void> main() async {
@@ -146,6 +150,8 @@ Future<void> main() async {
unawaited(_wireStorage());
unawaited(_wireConnectivity());
_wireIosAudioLifecycle();
_wireAndroidAudioLifecycle();
await _configureBundledVadModels();
runApp(const ChanoraApp());
}
@@ -214,6 +220,37 @@ void _wireIosAudioLifecycle() {
});
}
/// Wire the Android audio lifecycle MethodChannel.
///
/// Kotlin side (`AndroidAudioLifecycleController`) posts route-change
/// events through `FlutterMethodChannel` named
/// `"chanora/android_audio_lifecycle"`. This handler dispatches them to
/// the FRB bridge functions on the Rust side, mirroring the iOS pattern.
void _wireAndroidAudioLifecycle() {
if (!Platform.isAndroid) return;
const channel = MethodChannel('chanora/android_audio_lifecycle');
channel.setMethodCallHandler((call) async {
try {
switch (call.method) {
case 'handleRouteChange':
final args = call.arguments;
final routeStr = args is Map
? (args['routeType'] as String? ?? 'Unknown')
: (args as String? ?? 'Unknown');
final route = _parseBridgeAudioRoute(routeStr);
rust.handleRouteChange(route: route);
break;
default:
// Unknown method — ignore gracefully rather than crashing.
break;
}
} catch (_) {
// Errors from the Rust side are already logged there;
// don't propagate exceptions to the Android framework.
}
});
}
/// Populate `_kAppVersion` by suffixing the platform-canonical
/// build number to `_kSemverBaseline`. Format:
/// `v1.0.0-rc.8+<build>` (e.g. `v1.0.0-rc.8+64`). The build
@@ -728,6 +765,15 @@ class _BetaHomeState extends State<_BetaHome> {
_phase = _Phase.connected;
_applySnapshot(snap);
});
try {
await FlutterForegroundTask.startService(
notificationTitle: 'Chanora',
notificationText: 'Connected to ${snap.serverName}',
notificationButtons: const [
NotificationButton(id: 'disconnect', text: 'Disconnect'),
],
);
} catch (_) {}
} catch (e) {
if (!mounted) return;
final errorStr = e.toString();
@@ -1186,6 +1232,9 @@ class _BetaHomeState extends State<_BetaHome> {
try {
await rust.disconnect();
} catch (_) {}
try {
await FlutterForegroundTask.stopService();
} catch (_) {}
if (!mounted) return;
setState(() {
_phase = _Phase.idle;
@@ -1506,6 +1555,7 @@ class _BetaHomeState extends State<_BetaHome> {
icon: Icon(_hardMute ? Icons.mic_off : Icons.mic),
isSelected: _hardMute,
selectedIcon: const Icon(Icons.mic_off),
color: _hardMute ? theme.colorScheme.error : null,
onPressed: _onToggleHardMute,
),
IconButton(
@@ -1513,6 +1563,7 @@ class _BetaHomeState extends State<_BetaHome> {
icon: Icon(_outputMuted ? Icons.headset_off : Icons.headset),
isSelected: _outputMuted,
selectedIcon: const Icon(Icons.headset_off),
color: _outputMuted ? theme.colorScheme.error : null,
onPressed: _toggleOutputMute,
),
],
@@ -1532,6 +1583,10 @@ class _BetaHomeState extends State<_BetaHome> {
const headerTitle = SizedBox.shrink();
final appBarTitle = _phase == _Phase.connected
? Text(_snapshot?.serverName ?? l10n.appTitle, style: theme.textTheme.titleMedium)
: headerTitle;
final bodyContent = LayoutBuilder(
builder: (ctx, bodyConstraints) {
const wideBreakpoint = 600.0;
@@ -1554,7 +1609,8 @@ class _BetaHomeState extends State<_BetaHome> {
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (!isWideSnapshot) ...[banner, const SizedBox(height: 12)],
Text(statusText(), style: theme.textTheme.titleMedium),
if (_phase != _Phase.connected)
Text(statusText(), style: theme.textTheme.titleMedium),
if (_lostReason != null || _reconnectAttempt != null) ...[
const SizedBox(height: 8),
Container(
@@ -1707,6 +1763,8 @@ class _BetaHomeState extends State<_BetaHome> {
pttBoundKeyLabel: _pttBoundKeyLabel,
audioStats: _audioStats,
isTouchOnly: isTouchOnlyPttHost,
inputMuted: _hardMute,
outputMuted: _outputMuted,
onTap: () => _onOpenVoiceDetailsSheet(),
),
if (_inChannel &&
@@ -1756,7 +1814,7 @@ class _BetaHomeState extends State<_BetaHome> {
}
return Scaffold(
appBar: AppBar(title: headerTitle, actions: headerActions),
appBar: AppBar(title: appBarTitle, actions: headerActions),
body: SafeArea(
top: false,
child: Padding(padding: const EdgeInsets.all(16), child: bodyContent),
@@ -2168,6 +2226,75 @@ class _BookmarkList extends StatelessWidget {
///
/// Driven by the `BridgeEvent::PttCapability` stream published by
/// the `PttController` (SDD-088). The `_BetaHomeState` listener
class _WelcomeMessageTile extends StatefulWidget {
const _WelcomeMessageTile({required this.welcomeMessage});
final String welcomeMessage;
@override
State<_WelcomeMessageTile> createState() => _WelcomeMessageTileState();
}
class _WelcomeMessageTileState extends State<_WelcomeMessageTile> {
bool _expanded = true;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Container(
decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(6),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
InkWell(
onTap: () {
HapticFeedback.selectionClick();
setState(() => _expanded = !_expanded);
},
borderRadius: const BorderRadius.vertical(top: Radius.circular(6)),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
child: Row(
children: [
Icon(
_expanded ? Icons.expand_less : Icons.expand_more,
size: 18,
color: theme.colorScheme.onSurfaceVariant,
),
const SizedBox(width: 4),
Text(
'Server welcome message',
style: theme.textTheme.labelMedium?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
],
),
),
),
AnimatedCrossFade(
firstChild: Padding(
padding: const EdgeInsets.fromLTRB(8, 0, 8, 8),
child: BbCodeText(
widget.welcomeMessage,
linkTrust: LinkTrustService.instance,
),
),
secondChild: const SizedBox(width: double.infinity),
crossFadeState: _expanded
? CrossFadeState.showFirst
: CrossFadeState.showSecond,
duration: const Duration(milliseconds: 200),
),
],
),
);
}
}
/// updates the props on each transition.
class _SnapshotView extends StatelessWidget {
const _SnapshotView({
@@ -2242,17 +2369,7 @@ class _SnapshotView extends StatelessWidget {
),
if (snapshot.welcomeMessage.isNotEmpty) ...[
const SizedBox(height: 8),
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(6),
),
child: Text(
snapshot.welcomeMessage,
style: theme.textTheme.bodySmall,
),
),
_WelcomeMessageTile(welcomeMessage: snapshot.welcomeMessage),
],
const Divider(height: 24),
Text(l10n.channelsHeading, style: theme.textTheme.titleMedium),
@@ -2271,7 +2388,6 @@ class _SnapshotView extends StatelessWidget {
: null,
),
title: Text(ch.name),
subtitle: Text('id=${ch.id} parent=${ch.parent}'),
selected: ch.id == currentVoiceChannelId,
onTap:
hasJoinPending ||
@@ -2284,7 +2400,8 @@ class _SnapshotView extends StatelessWidget {
),
),
for (final cl in byChannel[ch.id] ?? const <rust.BridgeClient>[])
_clientTile(theme, cl, (depthById[ch.id] ?? 0) * indentPerLevel),
if (!cl.isServerQuery)
_clientTile(theme, cl, (depthById[ch.id] ?? 0) * indentPerLevel),
],
],
);