From e98f5cbf0f0353f6138d8772da1aaec570c7e482 Mon Sep 17 00:00:00 2001 From: cogwheel0 <172976095+cogwheel0@users.noreply.github.com> Date: Thu, 9 Oct 2025 00:01:35 +0530 Subject: [PATCH] feat: integrate flutter_local_notifications for enhanced voice call notifications - Added flutter_local_notifications dependency to manage notifications during voice calls. - Implemented notification handling in VoiceCallService to update call status and manage user interactions. - Enabled wake lock functionality to keep the screen on during calls and prevent audio interruptions. - Updated AndroidManifest.xml to include necessary permissions for Bluetooth and foreground services. - Enhanced notification actions to allow users to mute, unmute, or end calls directly from notifications. --- android/app/build.gradle.kts | 5 + android/app/src/main/AndroidManifest.xml | 8 +- .../app/src/main/res/drawable/ic_call_end.xml | 9 + .../app/src/main/res/drawable/ic_mic_off.xml | 9 + .../app/src/main/res/drawable/ic_mic_on.xml | 9 + .../voice_call_notification_service.dart | 225 ++++++++++++++++++ .../chat/services/voice_call_service.dart | 96 ++++++++ pubspec.lock | 40 ++++ pubspec.yaml | 1 + 9 files changed, 400 insertions(+), 2 deletions(-) create mode 100644 android/app/src/main/res/drawable/ic_call_end.xml create mode 100644 android/app/src/main/res/drawable/ic_mic_off.xml create mode 100644 android/app/src/main/res/drawable/ic_mic_on.xml create mode 100644 lib/features/chat/services/voice_call_notification_service.dart diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 6196ca2..511fe36 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -31,6 +31,8 @@ android { // Align with modern Android Gradle Plugin requirements sourceCompatibility = JavaVersion.VERSION_17 targetCompatibility = JavaVersion.VERSION_17 + // Enable core library desugaring for flutter_local_notifications + isCoreLibraryDesugaringEnabled = true } kotlinOptions { @@ -73,6 +75,9 @@ dependencies { implementation("androidx.activity:activity:1.9.2") implementation("androidx.core:core:1.13.1") implementation("androidx.activity:activity-ktx:1.9.2") + + // Core library desugaring for flutter_local_notifications + coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.4") } flutter { diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index f19a4e2..c3b95a5 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -4,11 +4,15 @@ - + + + + + @@ -88,7 +92,7 @@ android:name=".BackgroundStreamingService" android:enabled="true" android:exported="false" - android:foregroundServiceType="dataSync"/> + android:foregroundServiceType="dataSync|microphone"/> diff --git a/android/app/src/main/res/drawable/ic_call_end.xml b/android/app/src/main/res/drawable/ic_call_end.xml new file mode 100644 index 0000000..2aa2688 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_call_end.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/app/src/main/res/drawable/ic_mic_off.xml b/android/app/src/main/res/drawable/ic_mic_off.xml new file mode 100644 index 0000000..0c99e7e --- /dev/null +++ b/android/app/src/main/res/drawable/ic_mic_off.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/app/src/main/res/drawable/ic_mic_on.xml b/android/app/src/main/res/drawable/ic_mic_on.xml new file mode 100644 index 0000000..3711b71 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_mic_on.xml @@ -0,0 +1,9 @@ + + + diff --git a/lib/features/chat/services/voice_call_notification_service.dart b/lib/features/chat/services/voice_call_notification_service.dart new file mode 100644 index 0000000..41ca45b --- /dev/null +++ b/lib/features/chat/services/voice_call_notification_service.dart @@ -0,0 +1,225 @@ +import 'dart:io'; + +import 'package:flutter_local_notifications/flutter_local_notifications.dart'; + +/// Service to manage persistent notifications for voice calls +class VoiceCallNotificationService { + static final VoiceCallNotificationService _instance = + VoiceCallNotificationService._internal(); + factory VoiceCallNotificationService() => _instance; + VoiceCallNotificationService._internal(); + + final FlutterLocalNotificationsPlugin _notifications = + FlutterLocalNotificationsPlugin(); + + bool _initialized = false; + + // Notification IDs and channels + static const String _channelId = 'voice_call_channel'; + static const String _channelName = 'Voice Call'; + static const String _channelDescription = + 'Ongoing voice call notifications'; + static const int _notificationId = 2001; + + // Action IDs + static const String _actionMute = 'mute_call'; + static const String _actionUnmute = 'unmute_call'; + static const String _actionEndCall = 'end_call'; + + // Callback for handling notification actions + void Function(String action)? onActionPressed; + + Future initialize() async { + if (_initialized) return; + + const androidSettings = AndroidInitializationSettings('@mipmap/ic_launcher'); + const iosSettings = DarwinInitializationSettings( + requestAlertPermission: false, + requestBadgePermission: false, + requestSoundPermission: false, + ); + + const settings = InitializationSettings( + android: androidSettings, + iOS: iosSettings, + ); + + await _notifications.initialize( + settings, + onDidReceiveNotificationResponse: _handleNotificationResponse, + onDidReceiveBackgroundNotificationResponse: + _handleBackgroundNotificationResponse, + ); + + // Create notification channel for Android + if (Platform.isAndroid) { + await _createAndroidNotificationChannel(); + } + + _initialized = true; + } + + Future _createAndroidNotificationChannel() async { + const androidChannel = AndroidNotificationChannel( + _channelId, + _channelName, + description: _channelDescription, + importance: Importance.high, + playSound: false, + enableVibration: false, + showBadge: false, + ); + + await _notifications + .resolvePlatformSpecificImplementation< + AndroidFlutterLocalNotificationsPlugin>() + ?.createNotificationChannel(androidChannel); + } + + void _handleNotificationResponse(NotificationResponse response) { + final action = response.actionId; + if (action != null && onActionPressed != null) { + onActionPressed!(action); + } + } + + @pragma('vm:entry-point') + static void _handleBackgroundNotificationResponse( + NotificationResponse response, + ) { + // Background action handling + // Note: This runs in an isolate, so we can't directly call instance methods + // Actions will be handled when app returns to foreground + } + + /// Show ongoing voice call notification + Future showCallNotification({ + required String modelName, + required bool isMuted, + required bool isSpeaking, + }) async { + if (!_initialized) { + print('VoiceCallNotification: Initializing...'); + await initialize(); + } + + print('VoiceCallNotification: Showing notification for $modelName (muted: $isMuted, speaking: $isSpeaking)'); + + final status = isSpeaking ? 'Speaking...' : 'Listening...'; + final muteAction = isMuted ? 'Unmute' : 'Mute'; + final muteActionId = isMuted ? _actionUnmute : _actionMute; + + if (Platform.isAndroid) { + final androidDetails = AndroidNotificationDetails( + _channelId, + _channelName, + channelDescription: _channelDescription, + importance: Importance.high, + priority: Priority.high, + ongoing: true, + autoCancel: false, + playSound: false, + enableVibration: false, + showWhen: true, + usesChronometer: true, + chronometerCountDown: false, + category: AndroidNotificationCategory.call, + visibility: NotificationVisibility.public, + icon: '@mipmap/ic_launcher', + colorized: false, + actions: [ + AndroidNotificationAction( + muteActionId, + muteAction, + icon: DrawableResourceAndroidBitmap( + isMuted ? '@drawable/ic_mic_on' : '@drawable/ic_mic_off', + ), + showsUserInterface: false, + cancelNotification: false, + ), + AndroidNotificationAction( + _actionEndCall, + 'End Call', + icon: DrawableResourceAndroidBitmap('@drawable/ic_call_end'), + showsUserInterface: true, + cancelNotification: true, + ), + ], + ); + + await _notifications.show( + _notificationId, + 'Voice Call with $modelName', + status, + NotificationDetails(android: androidDetails), + ); + } else if (Platform.isIOS) { + // iOS doesn't support action buttons for ongoing notifications + // Use a simpler persistent notification + const iosDetails = DarwinNotificationDetails( + presentAlert: true, + presentBadge: false, + presentSound: false, + interruptionLevel: InterruptionLevel.timeSensitive, + ); + + await _notifications.show( + _notificationId, + 'Voice Call with $modelName', + status, + const NotificationDetails(iOS: iosDetails), + ); + } + } + + /// Update notification status + Future updateCallStatus({ + required String modelName, + required bool isMuted, + required bool isSpeaking, + }) async { + await showCallNotification( + modelName: modelName, + isMuted: isMuted, + isSpeaking: isSpeaking, + ); + } + + /// Cancel the voice call notification + Future cancelNotification() async { + await _notifications.cancel(_notificationId); + } + + /// Check if notifications are enabled + Future areNotificationsEnabled() async { + if (Platform.isAndroid) { + final androidImpl = _notifications.resolvePlatformSpecificImplementation< + AndroidFlutterLocalNotificationsPlugin>(); + return await androidImpl?.areNotificationsEnabled() ?? false; + } else if (Platform.isIOS) { + // iOS doesn't have a direct check, assume enabled if initialized + return _initialized; + } + return false; + } + + /// Request notification permissions + Future requestPermissions() async { + if (Platform.isAndroid) { + final androidImpl = _notifications.resolvePlatformSpecificImplementation< + AndroidFlutterLocalNotificationsPlugin>(); + final granted = await androidImpl?.requestNotificationsPermission(); + return granted ?? false; + } else if (Platform.isIOS) { + final iosImpl = _notifications + .resolvePlatformSpecificImplementation(); + final granted = await iosImpl?.requestPermissions( + alert: true, + badge: false, + sound: false, + ); + return granted ?? false; + } + return false; + } +} diff --git a/lib/features/chat/services/voice_call_service.dart b/lib/features/chat/services/voice_call_service.dart index 33fb027..a106a56 100644 --- a/lib/features/chat/services/voice_call_service.dart +++ b/lib/features/chat/services/voice_call_service.dart @@ -1,12 +1,14 @@ import 'dart:async'; import 'package:riverpod_annotation/riverpod_annotation.dart'; +import 'package:wakelock_plus/wakelock_plus.dart'; import '../../../core/providers/app_providers.dart'; import '../../../core/services/socket_service.dart'; import '../providers/chat_providers.dart'; import 'text_to_speech_service.dart'; import 'voice_input_service.dart'; +import 'voice_call_notification_service.dart'; part 'voice_call_service.g.dart'; @@ -25,13 +27,17 @@ class VoiceCallService { final TextToSpeechService _tts; final SocketService _socketService; final Ref _ref; + final VoiceCallNotificationService _notificationService = + VoiceCallNotificationService(); VoiceCallState _state = VoiceCallState.idle; String? _sessionId; + String? _activeConversationId; StreamSubscription? _transcriptSubscription; StreamSubscription? _intensitySubscription; String _accumulatedTranscript = ''; bool _isDisposed = false; + bool _isMuted = false; SocketEventSubscription? _socketSubscription; final StreamController _stateController = @@ -57,6 +63,9 @@ class VoiceCallService { onComplete: _handleTtsComplete, onError: _handleTtsError, ); + + // Set up notification action handler + _notificationService.onActionPressed = _handleNotificationAction; } VoiceCallState get state => _state; @@ -68,6 +77,16 @@ class VoiceCallService { Future initialize() async { if (_isDisposed) return; + // Initialize notification service + await _notificationService.initialize(); + + // Request notification permissions if needed + final notificationsEnabled = + await _notificationService.areNotificationsEnabled(); + if (!notificationsEnabled) { + await _notificationService.requestPermissions(); + } + // Initialize voice input final voiceInitialized = await _voiceInput.initialize(); if (!voiceInitialized) { @@ -97,8 +116,15 @@ class VoiceCallService { if (_isDisposed) return; try { + // Set conversation ID first before updating state + _activeConversationId = conversationId; + + // Update state (this will trigger notification) _updateState(VoiceCallState.connecting); + // Enable wake lock to keep screen on and prevent audio interruption + await WakelockPlus.enable(); + // Ensure socket connection await _socketService.ensureConnected(); _sessionId = _socketService.sessionId; @@ -119,6 +145,8 @@ class VoiceCallService { await _startListening(); } catch (e) { _updateState(VoiceCallState.error); + await WakelockPlus.disable(); + await _notificationService.cancelNotification(); rethrow; } } @@ -298,8 +326,16 @@ class VoiceCallService { await _voiceInput.stopListening(); await _tts.stop(); + // Cancel notification + await _notificationService.cancelNotification(); + + // Disable wake lock when call ends + await WakelockPlus.disable(); + _sessionId = null; + _activeConversationId = null; _accumulatedTranscript = ''; + _isMuted = false; _updateState(VoiceCallState.disconnected); } @@ -326,6 +362,60 @@ class VoiceCallService { if (_isDisposed) return; _state = newState; _stateController.add(newState); + + // Update notification when state changes (fire and forget) + _updateNotification().catchError((e) { + // Ignore notification errors + }); + } + + Future _updateNotification() async { + // Skip notification for idle, error, and disconnected states + if (_state == VoiceCallState.idle || + _state == VoiceCallState.error || + _state == VoiceCallState.disconnected) { + print('VoiceCall: Skipping notification - state: $_state'); + return; + } + + try { + final selectedModel = _ref.read(selectedModelProvider); + final modelName = selectedModel?.name ?? 'Assistant'; + + print('VoiceCall: Updating notification - model: $modelName, muted: $_isMuted, state: $_state'); + + await _notificationService.updateCallStatus( + modelName: modelName, + isMuted: _isMuted, + isSpeaking: _state == VoiceCallState.speaking, + ); + } catch (e) { + print('VoiceCall: Failed to update notification: $e'); + } + } + + void _handleNotificationAction(String action) { + switch (action) { + case 'mute_call': + _toggleMute(); + break; + case 'unmute_call': + _toggleMute(); + break; + case 'end_call': + stopCall(); + break; + } + } + + void _toggleMute() { + _isMuted = !_isMuted; + if (_isMuted) { + pauseListening(); + } else { + resumeListening(); + } + _updateNotification(); } Future dispose() async { @@ -338,6 +428,12 @@ class VoiceCallService { _voiceInput.dispose(); await _tts.dispose(); + // Cancel notification + await _notificationService.cancelNotification(); + + // Ensure wake lock is disabled on dispose + await WakelockPlus.disable(); + await _stateController.close(); await _transcriptController.close(); await _responseController.close(); diff --git a/pubspec.lock b/pubspec.lock index efa8c68..b75a579 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -438,6 +438,38 @@ packages: url: "https://pub.dev" source: hosted version: "6.0.0" + flutter_local_notifications: + dependency: "direct main" + description: + name: flutter_local_notifications + sha256: "7ed76be64e8a7d01dfdf250b8434618e2a028c9dfa2a3c41dc9b531d4b3fc8a5" + url: "https://pub.dev" + source: hosted + version: "19.4.2" + flutter_local_notifications_linux: + dependency: transitive + description: + name: flutter_local_notifications_linux + sha256: e3c277b2daab8e36ac5a6820536668d07e83851aeeb79c446e525a70710770a5 + url: "https://pub.dev" + source: hosted + version: "6.0.0" + flutter_local_notifications_platform_interface: + dependency: transitive + description: + name: flutter_local_notifications_platform_interface + sha256: "277d25d960c15674ce78ca97f57d0bae2ee401c844b6ac80fcd972a9c99d09fe" + url: "https://pub.dev" + source: hosted + version: "9.1.0" + flutter_local_notifications_windows: + dependency: transitive + description: + name: flutter_local_notifications_windows + sha256: "8d658f0d367c48bd420e7cf2d26655e2d1130147bca1eea917e576ca76668aaf" + url: "https://pub.dev" + source: hosted + version: "1.0.3" flutter_localizations: dependency: "direct main" description: flutter @@ -1514,6 +1546,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.6.11" + timezone: + dependency: transitive + description: + name: timezone + sha256: dd14a3b83cfd7cb19e7888f1cbc20f258b8d71b54c06f79ac585f14093a287d1 + url: "https://pub.dev" + source: hosted + version: "0.10.1" timing: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 59c0046..51bce66 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -67,6 +67,7 @@ dependencies: share_plus: ^12.0.0 share_handler: ^0.0.19 riverpod_annotation: ^3.0.0 + flutter_local_notifications: ^19.4.2 # Clipboard functionality is available through flutter/services (part of Flutter SDK)