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)