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
@@ -0,0 +1,98 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:url_launcher/url_launcher.dart';
class LinkTrustService extends ChangeNotifier {
static LinkTrustService? _instance;
final Set<String> _trusted = {};
bool _loaded = false;
static LinkTrustService get instance {
_instance ??= LinkTrustService._();
return _instance!;
}
LinkTrustService._() {
_load();
}
Future<void> _load() async {
if (_loaded) return;
_loaded = true;
final prefs = await SharedPreferences.getInstance();
final domains = prefs.getStringList('trusted_domains') ?? [];
_trusted.addAll(domains);
notifyListeners();
}
bool isTrusted(String host) {
host = host.toLowerCase();
for (final pattern in _trusted) {
if (_matches(host, pattern)) return true;
}
return false;
}
Future<void> addTrustedDomain(String host) async {
host = host.toLowerCase();
_trusted.add(host);
notifyListeners();
final prefs = await SharedPreferences.getInstance();
await prefs.setStringList('trusted_domains', _trusted.toList());
}
bool _matches(String host, String pattern) {
if (pattern.startsWith('*.')) {
final suffix = pattern.substring(2);
return host == suffix || host.endsWith('.$suffix');
}
return host == pattern;
}
}
Future<bool?> showLinkTrustDialog(BuildContext context, String domain) async {
bool remember = false;
return showDialog<bool>(
context: context,
builder: (ctx) => StatefulBuilder(
builder: (ctx, setDialogState) => AlertDialog(
title: const Text('Open external link?'),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('You are about to open a link to:\n\n$domain'),
const SizedBox(height: 12),
Row(
children: [
SizedBox(
width: 24,
height: 24,
child: Checkbox(
value: remember,
onChanged: (v) => setDialogState(() => remember = v ?? false),
),
),
const SizedBox(width: 8),
const Flexible(
child: Text('Trust all links from this domain'),
),
],
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.of(ctx).pop(null),
child: const Text('Cancel'),
),
TextButton(
onPressed: () => Navigator.of(ctx).pop(remember),
child: const Text('Open'),
),
],
),
),
);
}