diff --git a/android/app/proguard-rules.pro b/android/app/proguard-rules.pro index 6553110..98ce1f2 100644 --- a/android/app/proguard-rules.pro +++ b/android/app/proguard-rules.pro @@ -18,4 +18,7 @@ # Keep WebSocket functionality -keep class org.java_websocket.** { *; } --dontwarn org.java_websocket.** \ No newline at end of file +-dontwarn org.java_websocket.** + +# Keep Flutter CallKit Incoming classes +-keep class com.hiennv.flutter_callkit_incoming.** { *; } diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index e56683c..f9ffbda 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -47,7 +47,7 @@ - UIBackgroundModes - - audio - processing - + UIBackgroundModes + + audio + remote-notification + processing + voip + BGTaskSchedulerPermittedIdentifiers diff --git a/lib/core/providers/storage_providers.dart b/lib/core/providers/storage_providers.dart index c8de3bc..91ff513 100644 --- a/lib/core/providers/storage_providers.dart +++ b/lib/core/providers/storage_providers.dart @@ -33,3 +33,4 @@ final optimizedStorageServiceProvider = Provider(( workerManager: ref.watch(workerManagerProvider), ); }); + diff --git a/lib/core/services/callkit_service.dart b/lib/core/services/callkit_service.dart new file mode 100644 index 0000000..28aa9a7 --- /dev/null +++ b/lib/core/services/callkit_service.dart @@ -0,0 +1,184 @@ +import 'dart:developer' as developer; + +import 'package:flutter_callkit_incoming/entities/entities.dart'; +import 'package:flutter_callkit_incoming/flutter_callkit_incoming.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; +import 'package:uuid/uuid.dart'; + +part 'callkit_service.g.dart'; + +/// Thin wrapper around `flutter_callkit_incoming` for voice calls. +class CallKitService { + CallKitService({Uuid? uuid}) : _uuid = uuid ?? const Uuid(); + + final Uuid _uuid; + static const int _defaultCallDurationMs = 2 * 60 * 60 * 1000; // 2 hours + + /// Requests the notification/full-screen intent permissions needed on Android. + Future requestPermissions() async { + await _safe( + () => FlutterCallkitIncoming.requestNotificationPermission( + { + 'title': 'Notification permission', + 'rationaleMessagePermission': + 'Call alerts need notification permission.', + 'postNotificationMessageRequired': + 'Allow notifications to show incoming calls.', + }, + ), + ); + + await _safe(() => FlutterCallkitIncoming.requestFullIntentPermission()); + } + + /// Starts an outgoing call with the native UI and returns the call id. + Future startOutgoingVoiceCall({ + required String calleeName, + required String handle, + String? avatar, + int? durationMs, + }) async { + final id = _uuid.v4(); + final params = _buildParams( + id: id, + callerName: calleeName, + handle: handle, + avatar: avatar, + durationMs: durationMs ?? _defaultCallDurationMs, + ); + try { + await FlutterCallkitIncoming.startCall(params); + return id; + } catch (error, stackTrace) { + developer.log( + 'CallKit startCall failed: $error', + name: 'callkit', + error: error, + stackTrace: stackTrace, + ); + return null; + } + } + + /// Marks the current call as connected so iOS shows an incrementing timer. + Future markCallConnected(String id) async { + try { + await FlutterCallkitIncoming.setCallConnected(id); + } catch (error, stackTrace) { + developer.log( + 'CallKit setCallConnected failed: $error', + name: 'callkit', + error: error, + stackTrace: stackTrace, + ); + } + } + + /// Ends a specific call id. + Future endCall(String id) async { + await _safe(() => FlutterCallkitIncoming.endCall(id)); + } + + /// Clears all ongoing/missed calls. + Future endAllCalls() async { + await _safe(FlutterCallkitIncoming.endAllCalls); + } + + /// Returns the platform VOIP token (iOS PushKit) when available. + Future getVoipToken() async { + final token = await _safe( + () => FlutterCallkitIncoming.getDevicePushTokenVoIP(), + ); + if (token == null) return null; + if (token is String) return token; + return token.toString(); + } + + /// Returns the raw active call list from the plugin. + Future>> activeCalls() async { + final calls = await _safe(FlutterCallkitIncoming.activeCalls); + if (calls is List) { + return calls + .whereType>() + .map(Map.from) + .toList(); + } + return >[]; + } + + /// Stream of CallKit events from the native layer. + Stream get events => + FlutterCallkitIncoming.onEvent.where((event) => event != null).cast(); + + CallKitParams _buildParams({ + required String id, + required String callerName, + required String handle, + String? avatar, + int durationMs = _defaultCallDurationMs, + }) { + return CallKitParams( + id: id, + nameCaller: callerName, + appName: 'Conduit', + avatar: avatar, + handle: handle, + type: 0, // 0 = audio call + duration: durationMs, + textAccept: 'Accept', + textDecline: 'Decline', + missedCallNotification: const NotificationParams( + showNotification: true, + isShowCallback: true, + subtitle: 'Missed call', + callbackText: 'Call back', + ), + callingNotification: const NotificationParams( + showNotification: true, + isShowCallback: true, + subtitle: 'Calling...', + callbackText: 'Hang up', + ), + extra: const {'transport': 'voice'}, + android: const AndroidParams( + isCustomNotification: true, + isShowLogo: true, + ringtonePath: 'system_ringtone_default', + backgroundColor: '#0D1726', + actionColor: '#4CAF50', + incomingCallNotificationChannelName: 'Incoming Call', + missedCallNotificationChannelName: 'Missed Call', + ), + ios: const IOSParams( + handleType: 'generic', + supportsVideo: false, + audioSessionMode: 'default', + audioSessionActive: true, + audioSessionPreferredSampleRate: 44100.0, + audioSessionPreferredIOBufferDuration: 0.005, + supportsDTMF: true, + supportsHolding: true, + supportsGrouping: false, + supportsUngrouping: false, + ringtonePath: 'system_ringtone_default', + ), + ); + } + + Future _safe(Future Function() action) async { + try { + return await action(); + } catch (error, stackTrace) { + developer.log( + 'CallKit error: $error', + name: 'callkit', + error: error, + stackTrace: stackTrace, + ); + return null; + } + } +} + +@Riverpod(keepAlive: true) +CallKitService callKitService(Ref ref) => CallKitService(); diff --git a/lib/features/chat/services/text_to_speech_service.dart b/lib/features/chat/services/text_to_speech_service.dart index d25ac8d..95f86d6 100644 --- a/lib/features/chat/services/text_to_speech_service.dart +++ b/lib/features/chat/services/text_to_speech_service.dart @@ -69,6 +69,22 @@ class TextToSpeechService { break; } }); + + if (!kIsWeb && Platform.isIOS) { + final context = AudioContext( + iOS: AudioContextIOS( + category: AVAudioSessionCategory.playAndRecord, + options: const { + AVAudioSessionOptions.defaultToSpeaker, + AVAudioSessionOptions.mixWithOthers, + AVAudioSessionOptions.allowBluetooth, + AVAudioSessionOptions.allowBluetoothA2DP, + }, + ), + android: const AudioContextAndroid(), + ); + _player.setAudioContext(context); + } } Future _configureDeviceEngine({ @@ -87,12 +103,13 @@ class TextToSpeechService { if (!kIsWeb && Platform.isIOS) { await _tts.setSharedInstance(true); - await _tts.setIosAudioCategory(IosTextToSpeechAudioCategory.playback, [ - IosTextToSpeechAudioCategoryOptions.mixWithOthers, - IosTextToSpeechAudioCategoryOptions.defaultToSpeaker, - IosTextToSpeechAudioCategoryOptions.allowBluetooth, - IosTextToSpeechAudioCategoryOptions.allowBluetoothA2DP, - ]); + await _tts + .setIosAudioCategory(IosTextToSpeechAudioCategory.playAndRecord, [ + IosTextToSpeechAudioCategoryOptions.mixWithOthers, + IosTextToSpeechAudioCategoryOptions.defaultToSpeaker, + IosTextToSpeechAudioCategoryOptions.allowBluetooth, + IosTextToSpeechAudioCategoryOptions.allowBluetoothA2DP, + ]); } if (_engine != TtsEngine.server) { diff --git a/lib/features/chat/services/voice_call_service.dart b/lib/features/chat/services/voice_call_service.dart index 85c3a74..fb672bc 100644 --- a/lib/features/chat/services/voice_call_service.dart +++ b/lib/features/chat/services/voice_call_service.dart @@ -1,12 +1,15 @@ import 'dart:async'; import 'dart:collection'; +import 'dart:developer' as developer; import 'package:audioplayers/audioplayers.dart'; +import 'package:flutter_callkit_incoming/entities/call_event.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; import 'package:wakelock_plus/wakelock_plus.dart'; import '../../../core/providers/app_providers.dart'; import '../../../core/services/background_streaming_handler.dart'; +import '../../../core/services/callkit_service.dart'; import '../../../core/services/socket_service.dart'; import '../../../core/utils/markdown_to_text.dart'; import '../providers/chat_providers.dart'; @@ -38,6 +41,7 @@ class VoiceCallService { final TextToSpeechService _tts; final SocketService _socketService; final Ref _ref; + final CallKitService _callKitService; final VoiceCallNotificationService _notificationService = VoiceCallNotificationService(); @@ -64,6 +68,9 @@ class VoiceCallService { bool _serverPipelineActive = false; int _nextServerChunkId = 0; int _nextServerPlaybackId = 0; + bool _callKitPermissionsRequested = false; + String? _callKitCallId; + bool _callKitConnectedReported = false; final StreamController _stateController = StreamController.broadcast(); @@ -73,15 +80,18 @@ class VoiceCallService { StreamController.broadcast(); final StreamController _intensityController = StreamController.broadcast(); + StreamSubscription? _callKitEventSubscription; VoiceCallService({ required VoiceInputService voiceInput, required TextToSpeechService tts, required SocketService socketService, + required CallKitService callKitService, required Ref ref, }) : _voiceInput = voiceInput, _tts = tts, _socketService = socketService, + _callKitService = callKitService, _ref = ref { _tts.bindHandlers( onStart: _handleTtsStart, @@ -161,10 +171,124 @@ class VoiceCallService { ); } + Future _ensureCallKitPermissions() async { + if (_callKitPermissionsRequested) return; + _callKitPermissionsRequested = true; + await _callKitService.requestPermissions(); + } + + Future _startCallKitSession({required String modelName}) async { + try { + await _ensureCallKitPermissions(); + final callId = await _callKitService.startOutgoingVoiceCall( + calleeName: modelName, + handle: 'Conduit AI', + ); + _callKitCallId = callId; + _callKitConnectedReported = false; + } catch (error, stackTrace) { + developer.log( + 'CallKit outgoing setup failed: $error', + name: 'voice_call', + error: error, + stackTrace: stackTrace, + ); + } + } + + Future _endCallKitSession() async { + if (_callKitCallId == null) { + return; + } + final callId = _callKitCallId; + _callKitCallId = null; + _callKitConnectedReported = false; + try { + await _callKitService.endCall(callId!); + } catch (error, stackTrace) { + developer.log( + 'CallKit endCall failed: $error', + name: 'voice_call', + error: error, + stackTrace: stackTrace, + ); + await _callKitService.endAllCalls(); + } + } + + void _listenForCallKitEvents() { + _callKitEventSubscription?.cancel(); + if (_callKitCallId == null) return; + + _callKitEventSubscription = _callKitService.events.listen((callEvent) { + final eventId = _extractCallId(callEvent.body); + if (_callKitCallId != null && + eventId != null && + eventId != _callKitCallId) { + return; + } + + switch (callEvent.event) { + case Event.actionCallEnded: + case Event.actionCallDecline: + case Event.actionCallTimeout: + if (_state != VoiceCallState.disconnected) { + unawaited(stopCall()); + } + break; + case Event.actionCallToggleMute: + _handleCallKitMute(callEvent.body); + break; + case Event.actionCallToggleHold: + _handleCallKitHold(callEvent.body); + break; + case Event.actionCallConnected: + unawaited(_markCallKitConnected()); + break; + default: + break; + } + }); + } + + void _handleCallKitMute(dynamic body) { + final isMuted = body is Map ? body['isMuted'] == true : body == true; + if (_isMuted != isMuted) { + _toggleMute(); + } + } + + void _handleCallKitHold(dynamic body) { + final onHold = body is Map ? body['isOnHold'] == true : body == true; + if (onHold) { + unawaited(pauseListening(reason: VoiceCallPauseReason.system)); + } else { + unawaited(resumeListening(reason: VoiceCallPauseReason.system)); + } + } + + Future _markCallKitConnected() async { + if (_callKitCallId == null || _callKitConnectedReported) return; + await _callKitService.markCallConnected(_callKitCallId!); + _callKitConnectedReported = true; + } + + String? _extractCallId(dynamic body) { + if (body is Map) { + final id = body['id']; + return id?.toString(); + } + return null; + } + Future startCall(String? conversationId) async { if (_isDisposed) return; try { + final modelName = _ref.read(selectedModelProvider)?.name ?? 'Assistant'; + await _startCallKitSession(modelName: modelName); + _listenForCallKitEvents(); + // Update state (this will trigger notification) _updateState(VoiceCallState.connecting); @@ -200,15 +324,19 @@ class VoiceCallService { // Start listening for user voice input await _startListening(); + await _markCallKitConnected(); } catch (e) { _updateState(VoiceCallState.error); _keepAliveTimer?.cancel(); _keepAliveTimer = null; + await _callKitEventSubscription?.cancel(); + _callKitEventSubscription = null; await WakelockPlus.disable(); await _notificationService.cancelNotification(); await BackgroundStreamingHandler.instance.stopBackgroundExecution(const [ _voiceCallStreamId, ]); + await _endCallKitSession(); rethrow; } } @@ -551,10 +679,12 @@ class VoiceCallService { return; } _isSpeaking = false; + _listeningSuspendedForSpeech = false; if (_serverAudioBuffer.containsKey(_nextServerPlaybackId)) { _maybeStartServerAudio(); return; } + _responseCompleted = true; _maybeResumeListeningAfterSpeech(); } @@ -603,6 +733,7 @@ class VoiceCallService { return; } + _listeningSuspendedForSpeech = false; unawaited(_startListening()); } @@ -618,6 +749,8 @@ class VoiceCallService { unawaited(_startNextSpeechChunk()); return; } + _responseCompleted = true; + _listeningSuspendedForSpeech = false; _maybeResumeListeningAfterSpeech(); } @@ -641,6 +774,8 @@ class VoiceCallService { await _transcriptSubscription?.cancel(); await _intensitySubscription?.cancel(); + await _callKitEventSubscription?.cancel(); + _callKitEventSubscription = null; _socketSubscription?.dispose(); await _voiceInput.stopListening(); @@ -653,6 +788,7 @@ class VoiceCallService { // Cancel notification await _notificationService.cancelNotification(); + await _endCallKitSession(); // Disable wake lock when call ends await WakelockPlus.disable(); @@ -734,6 +870,10 @@ class VoiceCallService { } Future _updateNotification() async { + // When CallKit is active, rely on native UI instead of the ongoing + // notification to avoid duplicate surfaces. + if (_callKitCallId != null) return; + // Skip notification for idle, error, and disconnected states if (_state == VoiceCallState.idle || _state == VoiceCallState.error || @@ -801,6 +941,8 @@ class VoiceCallService { await _transcriptSubscription?.cancel(); await _intensitySubscription?.cancel(); + await _callKitEventSubscription?.cancel(); + _callKitEventSubscription = null; _socketSubscription?.dispose(); _voiceInput.dispose(); @@ -809,6 +951,7 @@ class VoiceCallService { // Cancel notification await _notificationService.cancelNotification(); + await _endCallKitSession(); // Ensure wake lock is disabled on dispose await WakelockPlus.disable(); @@ -830,6 +973,7 @@ VoiceCallService voiceCallService(Ref ref) { final api = ref.watch(apiServiceProvider); final tts = TextToSpeechService(api: api); final socketService = ref.watch(socketServiceProvider); + final callKit = ref.watch(callKitServiceProvider); if (socketService == null) { throw Exception('Socket service not available'); @@ -839,6 +983,7 @@ VoiceCallService voiceCallService(Ref ref) { voiceInput: voiceInput, tts: tts, socketService: socketService, + callKitService: callKit, ref: ref, ); diff --git a/lib/features/chat/services/voice_input_service.dart b/lib/features/chat/services/voice_input_service.dart index 9c1a7a4..1891705 100644 --- a/lib/features/chat/services/voice_input_service.dart +++ b/lib/features/chat/services/voice_input_service.dart @@ -5,13 +5,15 @@ import 'dart:typed_data'; import 'package:flutter/widgets.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:record/record.dart'; +import 'package:record/record.dart' + hide IosAudioCategory, IosAudioCategoryOptions; import 'package:riverpod_annotation/riverpod_annotation.dart'; import 'package:stts/stts.dart'; import 'package:vad/vad.dart'; import '../../../core/providers/app_providers.dart'; import '../../../core/services/api_service.dart'; +import '../../../core/services/background_streaming_handler.dart'; import '../../../core/services/settings_service.dart'; part 'voice_input_service.g.dart'; @@ -27,6 +29,7 @@ class VoiceInputService { static const int _vadSampleRate = 16000; static const int _vadFrameSamples = 1536; static const Duration _localeFetchTimeout = Duration(seconds: 2); + static const String _backgroundSttStreamId = 'voice-input-stt'; final VadHandler _vadHandler = VadHandler.create(); final Stt _speech = Stt(); @@ -36,11 +39,13 @@ class VoiceInputService { bool _isInitialized = false; bool _isListening = false; bool _localSttAvailable = false; + bool _localSttActive = false; SttPreference _preference = SttPreference.deviceOnly; bool _usingServerStt = false; String? _selectedLocaleId; List _locales = const []; bool _usingFallbackLocales = false; + Future? _startingLocalStt; StreamController? _textStreamController; String _currentText = ''; StreamController? _intensityController; @@ -49,6 +54,7 @@ class VoiceInputService { int _lastIntensity = 0; Timer? _intensityDecayTimer; List? _vadPendingSamples; + bool _backgroundMicPinned = false; Stream get textStream => _textStreamController?.stream ?? const Stream.empty(); @@ -266,22 +272,38 @@ class VoiceInputService { Future _startLocalRecognition({ required bool allowOnlineFallback, }) async { + if (_startingLocalStt != null) { + await _startingLocalStt; + } + final completer = Completer(); + _startingLocalStt = completer.future; + _localSttActive = false; + + await _ensureLocalSttReset(); + await _configureIosAudioSession(); + if (_selectedLocaleId != null) { await _speech.setLanguage(_selectedLocaleId!); } - Future attempt(bool offline) => _speech.start( - SttRecognitionOptions(punctuation: true, offline: offline), - ); + Future attempt(bool offline) async { + await _speech.start( + SttRecognitionOptions(punctuation: true, offline: offline), + ); + _localSttActive = true; + } try { await attempt(true); } catch (error) { + _localSttActive = false; + await _ensureLocalSttReset(); if (Platform.isIOS && allowOnlineFallback) { try { await attempt(false); return; } catch (secondary) { + await _ensureLocalSttReset(); throw Exception( 'On-device speech failed ($error); ' 'online fallback failed ($secondary).', @@ -289,16 +311,25 @@ class VoiceInputService { } } rethrow; + } finally { + completer.complete(); + _startingLocalStt = null; } } - Stream startListening() { + Future> startListening() async { if (!_isInitialized) { throw Exception('Voice input not initialized'); } + if (_startingLocalStt != null) { + try { + await _startingLocalStt; + } catch (_) {} + } + if (_isListening) { - unawaited(stopListening()); + await stopListening(); } _textStreamController = StreamController.broadcast(); @@ -316,62 +347,81 @@ class VoiceInputService { canUseLocal && _preference != SttPreference.serverOnly; final bool shouldUseServer = serverAvailable && - (_preference == SttPreference.serverOnly || !shouldUseLocal); + (_preference == SttPreference.serverOnly || + (!shouldUseLocal && _preference != SttPreference.deviceOnly)); if (shouldUseLocal) { + await _pinBackgroundMicrophone(); _autoStopTimer?.cancel(); _autoStopTimer = Timer(const Duration(seconds: 60), () { if (_isListening) { unawaited(_stopListening()); } }); + try { + final isStillAvailable = await _speech.isSupported(); + if (!isStillAvailable && _isListening) { + _localSttAvailable = false; + _textStreamController?.addError( + Exception('On-device speech recognition unavailable'), + ); + await _stopListening(); + return _textStreamController!.stream; + } + } catch (_) { + // ignore availability check errors + } - Future.microtask(() async { - try { - final isStillAvailable = await _speech.isSupported(); - if (!isStillAvailable && _isListening) { - _localSttAvailable = false; - _textStreamController?.addError( - Exception('On-device speech recognition unavailable'), - ); + _sttResultSub = _speech.onResultChanged.listen( + (SttRecognition result) { + if (!_isListening) return; + final prevLen = _currentText.length; + _currentText = result.text; + _textStreamController?.add(_currentText); + final delta = (_currentText.length - prevLen).clamp(0, 50); + final mapped = (delta / 5.0).ceil(); + _lastIntensity = mapped.clamp(0, 10); + try { + _intensityController?.add(_lastIntensity); + } catch (_) {} + if (result.isFinal) { unawaited(_stopListening()); } - } catch (_) { - // ignore availability check errors - } - }); - - _sttResultSub = _speech.onResultChanged.listen((SttRecognition result) { - if (!_isListening) return; - final prevLen = _currentText.length; - _currentText = result.text; - _textStreamController?.add(_currentText); - final delta = (_currentText.length - prevLen).clamp(0, 50); - final mapped = (delta / 5.0).ceil(); - _lastIntensity = mapped.clamp(0, 10); - try { - _intensityController?.add(_lastIntensity); - } catch (_) {} - if (result.isFinal) { - unawaited(_stopListening()); - } - }, onError: _handleLocalRecognizerError); - - _sttStateSub = _speech.onStateChanged.listen( - (_) {}, - onError: _handleLocalRecognizerError, + }, + onError: (error) { + debugPrint('Local STT Error: $error'); + _handleLocalRecognizerError(error); + }, ); - Future(() async { - try { - await _startLocalRecognition(allowOnlineFallback: !prefersDeviceOnly); - } catch (error) { - _localSttAvailable = false; - if (!_isListening) return; - _textStreamController?.addError(error); - await _stopListening(); + _sttStateSub = _speech.onStateChanged.listen( + (state) { + debugPrint('Local STT State: $state'); + if (state == SttState.start) { + _localSttActive = true; + } else if (state == SttState.stop) { + _localSttActive = false; + } + }, + onError: (error) { + debugPrint('Local STT State Error: $error'); + _handleLocalRecognizerError(error); + }, + ); + + try { + debugPrint('Starting local recognition...'); + await _startLocalRecognition(allowOnlineFallback: !prefersDeviceOnly); + debugPrint('Local recognition started'); + } catch (error) { + debugPrint('Failed to start local recognition: $error'); + _localSttAvailable = false; + if (!_isListening) { + return _textStreamController!.stream; } - }); + _textStreamController?.addError(error); + await _stopListening(); + } } else if (shouldUseServer) { _usingServerStt = true; _autoStopTimer?.cancel(); @@ -406,7 +456,7 @@ class VoiceInputService { }); } - return _textStreamController!.stream; + return _textStreamController?.stream ?? const Stream.empty(); } /// Centralized entry point to begin voice recognition. @@ -417,7 +467,7 @@ class VoiceInputService { if (!hasMic) { throw Exception('Microphone permission not granted'); } - return startListening(); + return await startListening(); } Future stopListening() async { @@ -453,9 +503,16 @@ class VoiceInputService { await _closeControllers(); _usingServerStt = false; + await _releaseBackgroundMicrophone(); } Future _stopLocalStt() async { + final pendingStart = _startingLocalStt; + if (pendingStart != null) { + try { + await pendingStart; + } catch (_) {} + } if (_sttResultSub != null) { try { await _sttResultSub?.cancel(); @@ -469,11 +526,67 @@ class VoiceInputService { _sttStateSub = null; } - if (_localSttAvailable) { + final shouldStopStt = _localSttActive && _localSttAvailable; + _localSttActive = false; + if (shouldStopStt) { try { await _speech.stop(); } catch (_) {} } + if (Platform.isIOS) { + try { + await _speech.ios?.setAudioSessionActive(false); + } catch (_) {} + } + } + + Future _pinBackgroundMicrophone() async { + if (!Platform.isIOS || _backgroundMicPinned) return; + try { + await BackgroundStreamingHandler.instance.startBackgroundExecution(const [ + _backgroundSttStreamId, + ], requiresMicrophone: true); + _backgroundMicPinned = true; + } catch (_) {} + } + + Future _releaseBackgroundMicrophone() async { + if (!Platform.isIOS || !_backgroundMicPinned) return; + _backgroundMicPinned = false; + try { + await BackgroundStreamingHandler.instance.stopBackgroundExecution(const [ + _backgroundSttStreamId, + ]); + } catch (_) {} + } + + Future _ensureLocalSttReset() async { + try { + await _speech.stop(); + } catch (_) {} + if (Platform.isIOS) { + try { + await _speech.ios?.setAudioSessionActive(false); + } catch (_) {} + } + } + + Future _configureIosAudioSession() async { + if (!Platform.isIOS) return; + final ios = _speech.ios; + if (ios == null) return; + try { + await ios.setAudioSessionCategory( + category: IosAudioCategory.playAndRecord, + options: [ + IosAudioCategoryOptions.allowBluetooth, + IosAudioCategoryOptions.allowBluetoothA2DP, + IosAudioCategoryOptions.defaultToSpeaker, + IosAudioCategoryOptions.duckOthers, + ], + ); + await ios.setAudioSessionActive(true); + } catch (_) {} } Future _startServerRecording() async { @@ -548,7 +661,9 @@ class VoiceInputService { Future _stopVadRecording() async { try { - await _vadHandler.stopListening(); + if (_isListening) { + await _vadHandler.stopListening(); + } } catch (_) {} await _vadSpeechEndSub?.cancel(); _vadSpeechEndSub = null; diff --git a/pubspec.lock b/pubspec.lock index 6d9d295..2ec1d34 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -486,6 +486,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.4.1" + flutter_callkit_incoming: + dependency: "direct main" + description: + name: flutter_callkit_incoming + sha256: "3589deb8b71e43f2d520a9c8a5240243f611062a8b246cdca4b1fda01fbbf9b8" + url: "https://pub.dev" + source: hosted + version: "3.0.0" flutter_lints: dependency: "direct dev" description: diff --git a/pubspec.yaml b/pubspec.yaml index f5e1dc0..d2583fe 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -73,6 +73,7 @@ dependencies: connectivity_plus: ^7.0.0 flutter_cache_manager: ^3.4.1 http: ^1.5.0 + flutter_callkit_incoming: ^3.0.0 # Clipboard functionality is available through flutter/services (part of Flutter SDK)