feat(voice): add iOS VAD runtime support
This commit is contained in:
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user