feat(callkit): Add CallKit service for native call UI and permissions
This commit is contained in:
5
android/app/proguard-rules.pro
vendored
5
android/app/proguard-rules.pro
vendored
@@ -18,4 +18,7 @@
|
||||
|
||||
# Keep WebSocket functionality
|
||||
-keep class org.java_websocket.** { *; }
|
||||
-dontwarn org.java_websocket.**
|
||||
-dontwarn org.java_websocket.**
|
||||
|
||||
# Keep Flutter CallKit Incoming classes
|
||||
-keep class com.hiennv.flutter_callkit_incoming.** { *; }
|
||||
|
||||
@@ -47,7 +47,7 @@
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
android:launchMode="singleTop"
|
||||
android:launchMode="singleInstance"
|
||||
android:theme="@style/LaunchTheme"
|
||||
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
|
||||
android:hardwareAccelerated="true"
|
||||
|
||||
@@ -4,6 +4,7 @@ PODS:
|
||||
- FlutterMacOS
|
||||
- connectivity_plus (0.0.1):
|
||||
- Flutter
|
||||
- CryptoSwift (1.8.4)
|
||||
- DKImagePickerController/Core (4.3.9):
|
||||
- DKImagePickerController/ImageDataManager
|
||||
- DKImagePickerController/Resource
|
||||
@@ -39,6 +40,9 @@ PODS:
|
||||
- DKImagePickerController/PhotoGallery
|
||||
- Flutter
|
||||
- Flutter (1.0.0)
|
||||
- flutter_callkit_incoming (0.0.1):
|
||||
- CryptoSwift
|
||||
- Flutter
|
||||
- flutter_local_notifications (0.0.1):
|
||||
- Flutter
|
||||
- flutter_native_splash (2.4.3):
|
||||
@@ -98,6 +102,7 @@ DEPENDENCIES:
|
||||
- connectivity_plus (from `.symlinks/plugins/connectivity_plus/ios`)
|
||||
- file_picker (from `.symlinks/plugins/file_picker/ios`)
|
||||
- Flutter (from `Flutter`)
|
||||
- flutter_callkit_incoming (from `.symlinks/plugins/flutter_callkit_incoming/ios`)
|
||||
- flutter_local_notifications (from `.symlinks/plugins/flutter_local_notifications/ios`)
|
||||
- flutter_native_splash (from `.symlinks/plugins/flutter_native_splash/ios`)
|
||||
- flutter_secure_storage (from `.symlinks/plugins/flutter_secure_storage/ios`)
|
||||
@@ -119,6 +124,7 @@ DEPENDENCIES:
|
||||
|
||||
SPEC REPOS:
|
||||
trunk:
|
||||
- CryptoSwift
|
||||
- DKImagePickerController
|
||||
- DKPhotoGallery
|
||||
- onnxruntime-c
|
||||
@@ -135,6 +141,8 @@ EXTERNAL SOURCES:
|
||||
:path: ".symlinks/plugins/file_picker/ios"
|
||||
Flutter:
|
||||
:path: Flutter
|
||||
flutter_callkit_incoming:
|
||||
:path: ".symlinks/plugins/flutter_callkit_incoming/ios"
|
||||
flutter_local_notifications:
|
||||
:path: ".symlinks/plugins/flutter_local_notifications/ios"
|
||||
flutter_native_splash:
|
||||
@@ -175,10 +183,12 @@ EXTERNAL SOURCES:
|
||||
SPEC CHECKSUMS:
|
||||
audioplayers_darwin: 4f9ca89d92d3d21cec7ec580e78ca888e5fb68bd
|
||||
connectivity_plus: cb623214f4e1f6ef8fe7403d580fdad517d2f7dd
|
||||
CryptoSwift: e64e11850ede528a02a0f3e768cec8e9d92ecb90
|
||||
DKImagePickerController: 946cec48c7873164274ecc4624d19e3da4c1ef3c
|
||||
DKPhotoGallery: b3834fecb755ee09a593d7c9e389d8b5d6deed60
|
||||
file_picker: a0560bc09d61de87f12d246fc47d2119e6ef37be
|
||||
Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467
|
||||
flutter_callkit_incoming: cb8138af67cda6dd981f7101a5d709003af21502
|
||||
flutter_local_notifications: a5a732f069baa862e728d839dd2ebb904737effb
|
||||
flutter_native_splash: c32d145d68aeda5502d5f543ee38c192065986cf
|
||||
flutter_secure_storage: 1ed9476fba7e7a782b22888f956cce43e2c62f13
|
||||
|
||||
@@ -87,11 +87,13 @@
|
||||
</dict>
|
||||
</array>
|
||||
<!-- Background Modes -->
|
||||
<key>UIBackgroundModes</key>
|
||||
<array>
|
||||
<string>audio</string>
|
||||
<string>processing</string>
|
||||
</array>
|
||||
<key>UIBackgroundModes</key>
|
||||
<array>
|
||||
<string>audio</string>
|
||||
<string>remote-notification</string>
|
||||
<string>processing</string>
|
||||
<string>voip</string>
|
||||
</array>
|
||||
<!-- Background Task Identifiers -->
|
||||
<key>BGTaskSchedulerPermittedIdentifiers</key>
|
||||
<array>
|
||||
|
||||
@@ -33,3 +33,4 @@ final optimizedStorageServiceProvider = Provider<OptimizedStorageService>((
|
||||
workerManager: ref.watch(workerManagerProvider),
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
184
lib/core/services/callkit_service.dart
Normal file
184
lib/core/services/callkit_service.dart
Normal file
@@ -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<void> requestPermissions() async {
|
||||
await _safe(
|
||||
() => FlutterCallkitIncoming.requestNotificationPermission(
|
||||
<String, dynamic>{
|
||||
'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<String?> 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<void> 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<void> endCall(String id) async {
|
||||
await _safe(() => FlutterCallkitIncoming.endCall(id));
|
||||
}
|
||||
|
||||
/// Clears all ongoing/missed calls.
|
||||
Future<void> endAllCalls() async {
|
||||
await _safe(FlutterCallkitIncoming.endAllCalls);
|
||||
}
|
||||
|
||||
/// Returns the platform VOIP token (iOS PushKit) when available.
|
||||
Future<String?> getVoipToken() async {
|
||||
final token = await _safe<dynamic>(
|
||||
() => 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<List<Map<String, dynamic>>> activeCalls() async {
|
||||
final calls = await _safe<dynamic>(FlutterCallkitIncoming.activeCalls);
|
||||
if (calls is List) {
|
||||
return calls
|
||||
.whereType<Map<dynamic, dynamic>>()
|
||||
.map(Map<String, dynamic>.from)
|
||||
.toList();
|
||||
}
|
||||
return <Map<String, dynamic>>[];
|
||||
}
|
||||
|
||||
/// Stream of CallKit events from the native layer.
|
||||
Stream<CallEvent> 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 <String, dynamic>{'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<T?> _safe<T>(Future<T> 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();
|
||||
@@ -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<void> _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) {
|
||||
|
||||
@@ -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<VoiceCallState> _stateController =
|
||||
StreamController<VoiceCallState>.broadcast();
|
||||
@@ -73,15 +80,18 @@ class VoiceCallService {
|
||||
StreamController<String>.broadcast();
|
||||
final StreamController<int> _intensityController =
|
||||
StreamController<int>.broadcast();
|
||||
StreamSubscription<CallEvent>? _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<void> _ensureCallKitPermissions() async {
|
||||
if (_callKitPermissionsRequested) return;
|
||||
_callKitPermissionsRequested = true;
|
||||
await _callKitService.requestPermissions();
|
||||
}
|
||||
|
||||
Future<void> _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<void> _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<void> _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<void> 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<void> _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,
|
||||
);
|
||||
|
||||
|
||||
@@ -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<LocaleName> _locales = const [];
|
||||
bool _usingFallbackLocales = false;
|
||||
Future<void>? _startingLocalStt;
|
||||
StreamController<String>? _textStreamController;
|
||||
String _currentText = '';
|
||||
StreamController<int>? _intensityController;
|
||||
@@ -49,6 +54,7 @@ class VoiceInputService {
|
||||
int _lastIntensity = 0;
|
||||
Timer? _intensityDecayTimer;
|
||||
List<double>? _vadPendingSamples;
|
||||
bool _backgroundMicPinned = false;
|
||||
|
||||
Stream<String> get textStream =>
|
||||
_textStreamController?.stream ?? const Stream<String>.empty();
|
||||
@@ -266,22 +272,38 @@ class VoiceInputService {
|
||||
Future<void> _startLocalRecognition({
|
||||
required bool allowOnlineFallback,
|
||||
}) async {
|
||||
if (_startingLocalStt != null) {
|
||||
await _startingLocalStt;
|
||||
}
|
||||
final completer = Completer<void>();
|
||||
_startingLocalStt = completer.future;
|
||||
_localSttActive = false;
|
||||
|
||||
await _ensureLocalSttReset();
|
||||
await _configureIosAudioSession();
|
||||
|
||||
if (_selectedLocaleId != null) {
|
||||
await _speech.setLanguage(_selectedLocaleId!);
|
||||
}
|
||||
|
||||
Future<void> attempt(bool offline) => _speech.start(
|
||||
SttRecognitionOptions(punctuation: true, offline: offline),
|
||||
);
|
||||
Future<void> 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<String> startListening() {
|
||||
Future<Stream<String>> 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<String>.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<String>.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<void> stopListening() async {
|
||||
@@ -453,9 +503,16 @@ class VoiceInputService {
|
||||
await _closeControllers();
|
||||
|
||||
_usingServerStt = false;
|
||||
await _releaseBackgroundMicrophone();
|
||||
}
|
||||
|
||||
Future<void> _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<void> _pinBackgroundMicrophone() async {
|
||||
if (!Platform.isIOS || _backgroundMicPinned) return;
|
||||
try {
|
||||
await BackgroundStreamingHandler.instance.startBackgroundExecution(const [
|
||||
_backgroundSttStreamId,
|
||||
], requiresMicrophone: true);
|
||||
_backgroundMicPinned = true;
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
Future<void> _releaseBackgroundMicrophone() async {
|
||||
if (!Platform.isIOS || !_backgroundMicPinned) return;
|
||||
_backgroundMicPinned = false;
|
||||
try {
|
||||
await BackgroundStreamingHandler.instance.stopBackgroundExecution(const [
|
||||
_backgroundSttStreamId,
|
||||
]);
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
Future<void> _ensureLocalSttReset() async {
|
||||
try {
|
||||
await _speech.stop();
|
||||
} catch (_) {}
|
||||
if (Platform.isIOS) {
|
||||
try {
|
||||
await _speech.ios?.setAudioSessionActive(false);
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _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<void> _startServerRecording() async {
|
||||
@@ -548,7 +661,9 @@ class VoiceInputService {
|
||||
|
||||
Future<void> _stopVadRecording() async {
|
||||
try {
|
||||
await _vadHandler.stopListening();
|
||||
if (_isListening) {
|
||||
await _vadHandler.stopListening();
|
||||
}
|
||||
} catch (_) {}
|
||||
await _vadSpeechEndSub?.cancel();
|
||||
_vadSpeechEndSub = null;
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user