Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b841d3f3e4 |
@@ -46,8 +46,7 @@ import AVFoundation
|
|||||||
//
|
//
|
||||||
// VoIP configuration is engaged on voice-channel join via the
|
// VoIP configuration is engaged on voice-channel join via the
|
||||||
// `chanora/ios_audio_session` MethodChannel, driven from Dart
|
// `chanora/ios_audio_session` MethodChannel, driven from Dart
|
||||||
// before `voiceJoin` starts VoiceProcessingIO and again as an
|
// by the BridgeEvent::AudioStarted / AudioStopped lifecycle.
|
||||||
// idempotent guard on the AudioStarted lifecycle.
|
|
||||||
do {
|
do {
|
||||||
try AVAudioSession.sharedInstance().setCategory(.ambient, mode: .default)
|
try AVAudioSession.sharedInstance().setCategory(.ambient, mode: .default)
|
||||||
logAudioSessionState(context: "launch-ambient")
|
logAudioSessionState(context: "launch-ambient")
|
||||||
@@ -80,8 +79,8 @@ import AVFoundation
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Activate the VoIP audio session. Called from Dart via the
|
/// Activate the VoIP audio session. Called from Dart via the
|
||||||
/// `chanora/ios_audio_session` channel before a voice channel join
|
/// `chanora/ios_audio_session` channel when a voice channel join
|
||||||
/// starts VoiceProcessingIO. Configures
|
/// reaches the `BridgeEvent::AudioStarted` stage. Configures
|
||||||
/// .playAndRecord + .voiceChat with .mixWithOthers so other apps
|
/// .playAndRecord + .voiceChat with .mixWithOthers so other apps
|
||||||
/// (Spotify, podcasts) can keep playing alongside the voice
|
/// (Spotify, podcasts) can keep playing alongside the voice
|
||||||
/// channel — matching the Telegram group-call UX. Idempotent:
|
/// channel — matching the Telegram group-call UX. Idempotent:
|
||||||
|
|||||||
@@ -34,7 +34,6 @@ import 'services/prefetch_debouncer.dart';
|
|||||||
import 'services/snapshot_state_mapper.dart';
|
import 'services/snapshot_state_mapper.dart';
|
||||||
import 'services/ts3_server_link.dart';
|
import 'services/ts3_server_link.dart';
|
||||||
import 'services/ui_preferences_service.dart';
|
import 'services/ui_preferences_service.dart';
|
||||||
import 'services/voice_join_ordering.dart';
|
|
||||||
import 'src/rust/api.dart' as rust;
|
import 'src/rust/api.dart' as rust;
|
||||||
import 'src/rust/frb_generated.dart';
|
import 'src/rust/frb_generated.dart';
|
||||||
import 'src/rust/lib.dart' as rust_err;
|
import 'src/rust/lib.dart' as rust_err;
|
||||||
@@ -1302,18 +1301,7 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
await joinVoiceChannelWithIosAudioSession(
|
await rust.voiceJoin(channelId: ch.id, password: password ?? '');
|
||||||
channelId: ch.id,
|
|
||||||
password: password ?? '',
|
|
||||||
voiceJoin: rust.voiceJoin,
|
|
||||||
activateIosAudioSession: iosAudioSessionController.activate,
|
|
||||||
deactivateIosAudioSession: iosAudioSessionController.deactivate,
|
|
||||||
// Server says we are already in the target channel: the user is
|
|
||||||
// still joined to a voice channel, so the iOS audio session must
|
|
||||||
// stay active. The catch below converts this rethrow into the
|
|
||||||
// success-on-already-joined branch.
|
|
||||||
isJoinSuccess: _isAlreadyInChannel,
|
|
||||||
);
|
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
setState(() {
|
setState(() {
|
||||||
_currentVoiceChannelId = ch.id;
|
_currentVoiceChannelId = ch.id;
|
||||||
|
|||||||
@@ -10,9 +10,9 @@ const iosAudioSessionChannelName = 'chanora/ios_audio_session';
|
|||||||
/// launch and leaves it inactive. The session is only switched to
|
/// launch and leaves it inactive. The session is only switched to
|
||||||
/// `.playAndRecord` + `.voiceChat` (with `.mixWithOthers`) while a
|
/// `.playAndRecord` + `.voiceChat` (with `.mixWithOthers`) while a
|
||||||
/// voice channel is actually active. This controller is the Dart
|
/// voice channel is actually active. This controller is the Dart
|
||||||
/// side of that contract — call [activate] before the Rust engine
|
/// side of that contract — call [activate] when the Rust engine
|
||||||
/// starts VoiceProcessingIO and [deactivate] on
|
/// emits `BridgeEvent::AudioStarted` and [deactivate] on
|
||||||
/// `BridgeEvent::AudioStopped` or failed joins.
|
/// `BridgeEvent::AudioStopped`.
|
||||||
///
|
///
|
||||||
/// On non-iOS platforms both methods are no-ops; the platforms
|
/// On non-iOS platforms both methods are no-ops; the platforms
|
||||||
/// handle their own session lifecycle elsewhere (Android via
|
/// handle their own session lifecycle elsewhere (Android via
|
||||||
|
|||||||
@@ -1,36 +0,0 @@
|
|||||||
typedef VoiceJoinCallback = Future<void> Function({
|
|
||||||
required BigInt channelId,
|
|
||||||
required String password,
|
|
||||||
});
|
|
||||||
|
|
||||||
typedef IosVoiceSessionActivation = Future<void> Function();
|
|
||||||
typedef IosVoiceSessionDeactivation = Future<void> Function();
|
|
||||||
|
|
||||||
/// Predicate used to recognise `voiceJoin` errors that the caller treats as a
|
|
||||||
/// successful join outcome (e.g. the server replied "already in channel").
|
|
||||||
///
|
|
||||||
/// When this returns `true` for a thrown error, the iOS audio session is kept
|
|
||||||
/// active because the user is still considered joined to the channel. The
|
|
||||||
/// error is still rethrown so the caller can run its success-on-already-joined
|
|
||||||
/// branch and update local state.
|
|
||||||
typedef VoiceJoinSuccessPredicate = bool Function(Object error);
|
|
||||||
|
|
||||||
Future<void> joinVoiceChannelWithIosAudioSession({
|
|
||||||
required BigInt channelId,
|
|
||||||
required String password,
|
|
||||||
required VoiceJoinCallback voiceJoin,
|
|
||||||
required IosVoiceSessionActivation activateIosAudioSession,
|
|
||||||
required IosVoiceSessionDeactivation deactivateIosAudioSession,
|
|
||||||
VoiceJoinSuccessPredicate? isJoinSuccess,
|
|
||||||
}) async {
|
|
||||||
await activateIosAudioSession();
|
|
||||||
try {
|
|
||||||
await voiceJoin(channelId: channelId, password: password);
|
|
||||||
} catch (e) {
|
|
||||||
if (isJoinSuccess != null && isJoinSuccess(e)) {
|
|
||||||
rethrow;
|
|
||||||
}
|
|
||||||
await deactivateIosAudioSession();
|
|
||||||
rethrow;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,123 +0,0 @@
|
|||||||
import 'package:flutter_test/flutter_test.dart';
|
|
||||||
|
|
||||||
import 'package:chanora_flutter/services/voice_join_ordering.dart';
|
|
||||||
|
|
||||||
void main() {
|
|
||||||
group('joinVoiceChannelWithIosAudioSession', () {
|
|
||||||
test('activates the iOS audio session before Rust voiceJoin', () async {
|
|
||||||
final calls = <String>[];
|
|
||||||
|
|
||||||
await joinVoiceChannelWithIosAudioSession(
|
|
||||||
channelId: BigInt.from(42),
|
|
||||||
password: 'secret',
|
|
||||||
activateIosAudioSession: () async {
|
|
||||||
calls.add('activateIosAudioSession');
|
|
||||||
},
|
|
||||||
deactivateIosAudioSession: () async {
|
|
||||||
calls.add('deactivateIosAudioSession');
|
|
||||||
},
|
|
||||||
voiceJoin: ({required channelId, required password}) async {
|
|
||||||
expect(channelId, BigInt.from(42));
|
|
||||||
expect(password, 'secret');
|
|
||||||
calls.add('voiceJoin');
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(calls, ['activateIosAudioSession', 'voiceJoin']);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('deactivates the iOS audio session when Rust voiceJoin fails',
|
|
||||||
() async {
|
|
||||||
final calls = <String>[];
|
|
||||||
|
|
||||||
await expectLater(
|
|
||||||
joinVoiceChannelWithIosAudioSession(
|
|
||||||
channelId: BigInt.from(42),
|
|
||||||
password: '',
|
|
||||||
activateIosAudioSession: () async {
|
|
||||||
calls.add('activateIosAudioSession');
|
|
||||||
},
|
|
||||||
deactivateIosAudioSession: () async {
|
|
||||||
calls.add('deactivateIosAudioSession');
|
|
||||||
},
|
|
||||||
voiceJoin: ({required channelId, required password}) async {
|
|
||||||
calls.add('voiceJoin');
|
|
||||||
throw StateError('join rejected');
|
|
||||||
},
|
|
||||||
),
|
|
||||||
throwsStateError,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(calls, [
|
|
||||||
'activateIosAudioSession',
|
|
||||||
'voiceJoin',
|
|
||||||
'deactivateIosAudioSession',
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test(
|
|
||||||
'keeps the iOS audio session active when voiceJoin throws but the '
|
|
||||||
'error is recognised as already-in-channel (treated as success); '
|
|
||||||
'still rethrows so the caller runs its success-on-already-joined branch',
|
|
||||||
() async {
|
|
||||||
final calls = <String>[];
|
|
||||||
|
|
||||||
await expectLater(
|
|
||||||
joinVoiceChannelWithIosAudioSession(
|
|
||||||
channelId: BigInt.from(42),
|
|
||||||
password: '',
|
|
||||||
activateIosAudioSession: () async {
|
|
||||||
calls.add('activateIosAudioSession');
|
|
||||||
},
|
|
||||||
deactivateIosAudioSession: () async {
|
|
||||||
calls.add('deactivateIosAudioSession');
|
|
||||||
},
|
|
||||||
voiceJoin: ({required channelId, required password}) async {
|
|
||||||
calls.add('voiceJoin');
|
|
||||||
throw _FakeAlreadyInChannel();
|
|
||||||
},
|
|
||||||
isJoinSuccess: (error) => error is _FakeAlreadyInChannel,
|
|
||||||
),
|
|
||||||
throwsA(isA<_FakeAlreadyInChannel>()),
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(calls, ['activateIosAudioSession', 'voiceJoin']);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
test(
|
|
||||||
'deactivates the iOS audio session when isJoinSuccess returns false '
|
|
||||||
'for a non-success error',
|
|
||||||
() async {
|
|
||||||
final calls = <String>[];
|
|
||||||
|
|
||||||
await expectLater(
|
|
||||||
joinVoiceChannelWithIosAudioSession(
|
|
||||||
channelId: BigInt.from(42),
|
|
||||||
password: '',
|
|
||||||
activateIosAudioSession: () async {
|
|
||||||
calls.add('activateIosAudioSession');
|
|
||||||
},
|
|
||||||
deactivateIosAudioSession: () async {
|
|
||||||
calls.add('deactivateIosAudioSession');
|
|
||||||
},
|
|
||||||
voiceJoin: ({required channelId, required password}) async {
|
|
||||||
calls.add('voiceJoin');
|
|
||||||
throw StateError('join rejected');
|
|
||||||
},
|
|
||||||
isJoinSuccess: (error) => error is _FakeAlreadyInChannel,
|
|
||||||
),
|
|
||||||
throwsStateError,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(calls, [
|
|
||||||
'activateIosAudioSession',
|
|
||||||
'voiceJoin',
|
|
||||||
'deactivateIosAudioSession',
|
|
||||||
]);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
class _FakeAlreadyInChannel implements Exception {}
|
|
||||||
@@ -10967,4 +10967,3 @@ cargo about generate --output-file docs/security/license-inventory.html about.hb
|
|||||||
|
|
||||||
This artefact supports the DEC-012 legal review handoff at
|
This artefact supports the DEC-012 legal review handoff at
|
||||||
`docs/governance/legal-review-readiness.md`.
|
`docs/governance/legal-review-readiness.md`.
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user