fix(voice,ios): scope AVAudioSession VoiceChat to call lifetime (#33)

Adopt a call-scoped VoIP audio session lifecycle so other apps' audio is
not stopped while Chanora is idle and the in-call session does not get
clobbered by media-server resets unrelated to voice.

AppDelegate.swift
- Set .ambient + .mixWithOthers as the idle baseline so the app does not
  hold a VoiceChat session when no call is active.
- Switch to .playAndRecord + .voiceChat + .mixWithOthers + .duckOthers
  on demand via the new chanora/ios_audio_session MethodChannel, and
  revert to .ambient on deactivate.
- Gate the media-services-reset rebuild on voiceSessionActive so a
  stray reset during idle no longer reactivates VoiceChat.

ios_audio_session_controller.dart (new)
- Thin Dart wrapper around chanora/ios_audio_session with activate/
  deactivate; no-op on non-iOS; swallows PlatformException to keep
  audio start/stop resilient to platform-side races.

main.dart
- Activate the iOS audio session on BridgeEvent_AudioStarted, deactivate
  on BridgeEvent_AudioStopped, fire-and-forget via unawaited().

ios_voice_unit.rs
- Add the 10 local bindings required by the render-callback closure
  preamble (wav_recorder_for_render, render_recorder_active,
  render_ref_len, render_ref_accum, cb_count, last_num_frames,
  num_frames_changes, callbacks_with_audio, callbacks_with_silence)
  so the iOS target compiles cleanly with the new lifecycle wiring.

Tests
- 5 unit tests in test/services/ios_audio_session_controller_test.dart
  cover activate/deactivate on iOS, no-op on non-iOS, and graceful
  PlatformException handling.

Docs
- SRS SRS-110 expanded to cover the call-scoped lifecycle invariant.
- SysDes mobile-voice row updated to reflect the MethodChannel and
  .ambient idle baseline.
- implementation-status-2026-05-28 voiceChat row flipped to done.

Verification
- flutter analyze: No issues found (2.8s)
- flutter test: 195 passed / 2 skipped / 0 failed
- cargo build -p chanora_audio --target aarch64-apple-ios: clean
- cargo build -p chanora_audio (macOS host): clean

Device QA matrix (Spotify-keeps-playing-while-idle, mix-during-call,
revert-on-call-end, media-services-reset-during-idle) remains pending
on physical hardware.
This commit is contained in:
Edison Jwa
2026-06-08 06:02:12 +09:00
committed by GitHub
parent 68a7892e19
commit a0ff17b935
8 changed files with 294 additions and 125 deletions
@@ -0,0 +1,88 @@
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);
});
});
}