feat(voice): add iOS VAD runtime support

This commit is contained in:
Edison Jwa
2026-05-21 20:51:45 +09:00
parent 171baf6e41
commit 6af4ecab0f
73 changed files with 11529 additions and 1249 deletions
+39 -28
View File
@@ -22,39 +22,50 @@ import 'package:flutter_test/flutter_test.dart';
import 'package:chanora_flutter/src/rust/api.dart' as rust;
import 'package:chanora_flutter/src/rust/frb_generated.dart';
const _runE2e = bool.fromEnvironment('CHANORA_RUN_E2E');
const _skipReason =
'Set --dart-define=CHANORA_RUN_E2E=true with a built native bridge to run '
'network end-to-end acceptance.';
void main() {
setUpAll(() async {
if (!_runE2e) return;
await RustLib.init();
});
test('connect/snapshot/disconnect against cn.teamspeak.app', () async {
// Defensive: in case a previous test left a connection open.
try {
test(
'connect/snapshot/disconnect against cn.teamspeak.app',
() async {
// Defensive: in case a previous test left a connection open.
try {
await rust.disconnect();
} catch (_) {}
final snap = await rust.connect(
host: 'cn.teamspeak.app',
nickname: 'ChanoraAlphaTest',
password: '',
);
expect(snap.serverName, isNotEmpty);
expect(snap.channels, isNotEmpty);
// Welcome message is allowed to be empty on some servers; just
// assert it's a String type (which it always is — this is more a
// smoke than a real assertion).
expect(snap.welcomeMessage, isA<String>());
// Re-fetch the snapshot; should still succeed.
final snap2 = await rust.snapshot();
expect(snap2.serverName, snap.serverName);
final connectedBefore = await rust.isConnected();
expect(connectedBefore, isTrue);
await rust.disconnect();
} catch (_) {}
final snap = await rust.connect(
host: 'cn.teamspeak.app',
nickname: 'ChanoraAlphaTest',
password: '',
);
expect(snap.serverName, isNotEmpty);
expect(snap.channels, isNotEmpty);
// Welcome message is allowed to be empty on some servers; just
// assert it's a String type (which it always is — this is more a
// smoke than a real assertion).
expect(snap.welcomeMessage, isA<String>());
// Re-fetch the snapshot; should still succeed.
final snap2 = await rust.snapshot();
expect(snap2.serverName, snap.serverName);
final connectedBefore = await rust.isConnected();
expect(connectedBefore, isTrue);
await rust.disconnect();
final connectedAfter = await rust.isConnected();
expect(connectedAfter, isFalse);
}, timeout: const Timeout(Duration(seconds: 30)));
final connectedAfter = await rust.isConnected();
expect(connectedAfter, isFalse);
},
timeout: const Timeout(Duration(seconds: 30)),
skip: _runE2e ? false : _skipReason,
);
}
+62 -49
View File
@@ -23,60 +23,73 @@ import 'package:flutter_test/flutter_test.dart';
import 'package:chanora_flutter/src/rust/api.dart' as rust;
import 'package:chanora_flutter/src/rust/frb_generated.dart';
const _runE2e = bool.fromEnvironment('CHANORA_RUN_E2E');
const _skipReason =
'Set --dart-define=CHANORA_RUN_E2E=true with a built native bridge to run '
'network end-to-end acceptance.';
void main() {
setUpAll(() async {
if (!_runE2e) return;
await RustLib.init();
});
test('connect → start_audio → PTT cycle → disconnect', () async {
// Defensive cleanup in case a previous test left state.
try {
test(
'connect → start_audio → PTT cycle → disconnect',
() async {
// Defensive cleanup in case a previous test left state.
try {
await rust.disconnect();
} catch (_) {}
final snap = await rust.connect(
host: 'cn.teamspeak.app',
nickname: 'ChanoraBetaTest',
password: '',
);
expect(snap.serverName, isNotEmpty);
expect(snap.channels, isNotEmpty);
await rust.voiceJoin(channelId: snap.channels.first.id, password: '');
// Zero out the release tail so set_ptt(false) takes effect
// synchronously — the default 200 ms tail (SDD-096) would
// otherwise delay the assertion below.
await rust.setReleaseTailMs(ms: 0);
// Initial stats: PTT off, no frames sent yet.
final s0 = await rust.audioStats();
expect(s0.pttActive, isFalse);
expect(s0.framesSent, 0);
// Press PTT, wait ~250 ms, then read stats. If the host has a
// real microphone the encoder will emit ~10-12 frames. If the
// host has only a null source (typical headless), capture will
// have logged a warning at startAudio time and run in
// playback-only mode; framesSent stays at 0. Either outcome is
// a successful test of the wiring — what we actually verify
// here is that the PTT flag changes and no exception is thrown.
await rust.setPtt(active: true);
await Future<void>.delayed(const Duration(milliseconds: 250));
final s1 = await rust.audioStats();
expect(s1.pttActive, isTrue);
await rust.setPtt(active: false);
// Give the release-tail (set to 0 above) one tick to settle.
await Future<void>.delayed(const Duration(milliseconds: 50));
final s2 = await rust.audioStats();
expect(s2.pttActive, isFalse);
await rust.disconnect();
} catch (_) {}
final connectedAfter = await rust.isConnected();
expect(connectedAfter, isFalse);
final snap = await rust.connect(
host: 'cn.teamspeak.app',
nickname: 'ChanoraBetaTest',
password: '',
);
expect(snap.serverName, isNotEmpty);
expect(snap.channels, isNotEmpty);
await rust.voiceJoin(channelId: snap.channels.first.id, password: '');
// Zero out the release tail so set_ptt(false) takes effect
// synchronously — the default 200 ms tail (SDD-096) would
// otherwise delay the assertion below.
await rust.setReleaseTailMs(ms: 0);
// Initial stats: PTT off, no frames sent yet.
final s0 = await rust.audioStats();
expect(s0.pttActive, isFalse);
expect(s0.framesSent, 0);
// Press PTT, wait ~250 ms, then read stats. If the host has a
// real microphone the encoder will emit ~10-12 frames. If the
// host has only a null source (typical headless), capture will
// have logged a warning at startAudio time and run in
// playback-only mode; framesSent stays at 0. Either outcome is
// a successful test of the wiring — what we actually verify
// here is that the PTT flag changes and no exception is thrown.
await rust.setPtt(active: true);
await Future<void>.delayed(const Duration(milliseconds: 250));
final s1 = await rust.audioStats();
expect(s1.pttActive, isTrue);
await rust.setPtt(active: false);
// Give the release-tail (set to 0 above) one tick to settle.
await Future<void>.delayed(const Duration(milliseconds: 50));
final s2 = await rust.audioStats();
expect(s2.pttActive, isFalse);
await rust.disconnect();
final connectedAfter = await rust.isConnected();
expect(connectedAfter, isFalse);
// ignore: avoid_print
print('Beta E2E: TX=${s1.framesSent} frames, RX=${s1.framesReceived} frames');
}, timeout: const Timeout(Duration(seconds: 30)));
// ignore: avoid_print
print(
'Beta E2E: TX=${s1.framesSent} frames, RX=${s1.framesReceived} frames',
);
},
timeout: const Timeout(Duration(seconds: 30)),
skip: _runE2e ? false : _skipReason,
);
}
+206 -17
View File
@@ -11,35 +11,224 @@
// Verification-plan rows: SWE4-UV-014, SWE4-UV-019, SWE4-UV-020 (swe4-unit-verification-plan.md).
import 'package:flutter/material.dart';
import 'package:flutter/semantics.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
// ignore_for_file: deprecated_member_use
import 'package:chanora_flutter/l10n/generated/app_localizations.dart';
import 'package:chanora_flutter/services/android_permissions_service.dart';
import 'package:chanora_flutter/services/ios_permissions_service.dart';
import 'package:chanora_flutter/widgets/permission_state_banner.dart';
import 'package:chanora_flutter/widgets/voice_compact.dart';
void main() {
testWidgets('renders English banner', (tester) async {
await tester.pumpWidget(MaterialApp(
localizationsDelegates: AppL10n.localizationsDelegates,
supportedLocales: AppL10n.supportedLocales,
home: Builder(builder: (ctx) {
final l10n = AppL10n.of(ctx);
return Scaffold(body: Text(l10n.homeNotProductionReadyBanner));
}),
));
await tester.pumpWidget(
MaterialApp(
localizationsDelegates: AppL10n.localizationsDelegates,
supportedLocales: AppL10n.supportedLocales,
home: Builder(
builder: (ctx) {
final l10n = AppL10n.of(ctx);
return Scaffold(body: Text(l10n.homeNotProductionReadyBanner));
},
),
),
);
await tester.pumpAndSettle();
expect(find.textContaining('Beta build'), findsOneWidget);
});
testWidgets('renders Simplified Chinese banner', (tester) async {
await tester.pumpWidget(MaterialApp(
locale: const Locale('zh'),
localizationsDelegates: AppL10n.localizationsDelegates,
supportedLocales: AppL10n.supportedLocales,
home: Builder(builder: (ctx) {
final l10n = AppL10n.of(ctx);
return Scaffold(body: Text(l10n.homeNotProductionReadyBanner));
}),
));
await tester.pumpWidget(
MaterialApp(
locale: const Locale('zh'),
localizationsDelegates: AppL10n.localizationsDelegates,
supportedLocales: AppL10n.supportedLocales,
home: Builder(
builder: (ctx) {
final l10n = AppL10n.of(ctx);
return Scaffold(body: Text(l10n.homeNotProductionReadyBanner));
},
),
),
);
await tester.pumpAndSettle();
expect(find.textContaining('Beta 版本'), findsOneWidget);
});
testWidgets('localizes diagnostic export save action', (tester) async {
await tester.pumpWidget(
MaterialApp(
localizationsDelegates: AppL10n.localizationsDelegates,
supportedLocales: AppL10n.supportedLocales,
home: Builder(
builder: (ctx) {
final l10n = AppL10n.of(ctx);
return Scaffold(
body: Column(
children: [
Text(l10n.diagnosticsSaveAction),
Text(l10n.diagnosticsSaved('/tmp/chanora-diagnostics.txt')),
],
),
);
},
),
),
);
await tester.pumpAndSettle();
expect(find.text('Save export'), findsOneWidget);
expect(find.textContaining('chanora-diagnostics.txt'), findsOneWidget);
});
testWidgets('on-screen PTT exposes hold-to-talk semantics', (tester) async {
final semantics = tester.ensureSemantics();
await tester.pumpWidget(
MaterialApp(
localizationsDelegates: AppL10n.localizationsDelegates,
supportedLocales: AppL10n.supportedLocales,
home: Scaffold(
body: VoicePttButton(active: false, onHeldChanged: (_) {}),
),
),
);
await tester.pumpAndSettle();
final node = tester.getSemantics(find.byType(VoicePttButton));
expect(node.label, 'Hold to talk');
expect(node.hint, 'Press and hold to transmit voice; release to stop.');
expect(node.hasFlag(SemanticsFlag.isButton), isTrue);
expect(node.hasFlag(SemanticsFlag.isLiveRegion), isTrue);
semantics.dispose();
});
testWidgets('permission banner grants denied microphone access', (
tester,
) async {
final state = ValueNotifier(AndroidRecordAudioPermissionState.denied);
var grantCount = 0;
await tester.pumpWidget(
MaterialApp(
localizationsDelegates: AppL10n.localizationsDelegates,
supportedLocales: AppL10n.supportedLocales,
home: Scaffold(
body: PermissionStateBanner.fromCallbacks(
recordAudioState: state,
ensureRecordAudio: () async {
grantCount += 1;
return AndroidRecordAudioPermissionState.granted;
},
openAppSettings: () async {},
),
),
),
);
expect(find.text('Grant'), findsOneWidget);
await tester.tap(find.text('Grant'));
expect(grantCount, 1);
state.dispose();
});
testWidgets('permission banner opens settings after permanent denial', (
tester,
) async {
final state = ValueNotifier(
AndroidRecordAudioPermissionState.permanentlyDenied,
);
var settingsCount = 0;
await tester.pumpWidget(
MaterialApp(
localizationsDelegates: AppL10n.localizationsDelegates,
supportedLocales: AppL10n.supportedLocales,
home: Scaffold(
body: PermissionStateBanner.fromCallbacks(
recordAudioState: state,
ensureRecordAudio: () async =>
AndroidRecordAudioPermissionState.permanentlyDenied,
openAppSettings: () async {
settingsCount += 1;
},
),
),
),
);
expect(find.text('Open System Settings'), findsOneWidget);
await tester.tap(find.text('Open System Settings'));
expect(settingsCount, 1);
state.dispose();
});
testWidgets('on-screen PTT reports press and release gestures', (
tester,
) async {
final states = <bool>[];
await tester.pumpWidget(
MaterialApp(
localizationsDelegates: AppL10n.localizationsDelegates,
supportedLocales: AppL10n.supportedLocales,
home: Scaffold(
body: VoicePttButton(active: false, onHeldChanged: states.add),
),
),
);
final center = tester.getCenter(find.byType(VoicePttButton));
final gesture = await tester.startGesture(center);
await tester.pump();
await gesture.up();
await tester.pump();
expect(states, [true, false]);
});
testWidgets('iOS permission service maps channel states and settings', (
tester,
) async {
const channel = MethodChannel(iosPlatformChannelName);
final methods = <String>[];
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(channel, (call) async {
methods.add(call.method);
switch (call.method) {
case methodGetMicrophonePermissionState:
return 'NotDetermined';
case methodRequestMicrophonePermission:
return 'Granted';
case methodIosOpenAppSettings:
return true;
}
return null;
});
final service = IosPermissionsService(channel: channel);
await service.start();
expect(
service.recordAudioState.value,
AndroidRecordAudioPermissionState.denied,
);
expect(
await service.ensureRecordAudio(),
AndroidRecordAudioPermissionState.granted,
);
await service.openAppSettings();
expect(methods, [
methodGetMicrophonePermissionState,
methodRequestMicrophonePermission,
methodIosOpenAppSettings,
]);
service.dispose();
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(channel, null);
});
}