From 2ef49a29749fb3f65e533616e2ef364ac0b74854 Mon Sep 17 00:00:00 2001 From: cogwheel0 <172976095+cogwheel0@users.noreply.github.com> Date: Sat, 29 Nov 2025 13:30:31 +0530 Subject: [PATCH] feat(voice-call): Improve socket connection and mic permission handling --- lib/core/services/app_intents_service.dart | 10 ++++ lib/core/utils/android_assistant_handler.dart | 11 +++++ .../chat/services/voice_call_service.dart | 21 +++++--- .../chat/services/voice_input_service.dart | 49 ++++++++++++++++++- 4 files changed, 83 insertions(+), 8 deletions(-) diff --git a/lib/core/services/app_intents_service.dart b/lib/core/services/app_intents_service.dart index 8273e95..1026fdb 100644 --- a/lib/core/services/app_intents_service.dart +++ b/lib/core/services/app_intents_service.dart @@ -576,6 +576,16 @@ class AppIntentCoordinator extends _$AppIntentCoordinator { throw StateError('Choose a model before starting a voice call.'); } + // Pre-warm socket connection before navigating to voice call. + // This reduces the chance of "websocket not connected" errors when + // opening voice call right after app start or from deep links. + final socketService = ref.read(socketServiceProvider); + if (socketService != null && !socketService.isConnected) { + // Start connection attempt in parallel, don't wait for full connection + // The VoiceCallService.startCall() will wait with extended timeout + unawaited(socketService.connect()); + } + await NavigationService.navigateToChat(); // Wait a tick for navigation to settle so navigator/context are present. diff --git a/lib/core/utils/android_assistant_handler.dart b/lib/core/utils/android_assistant_handler.dart index e889fd9..83fbfd4 100644 --- a/lib/core/utils/android_assistant_handler.dart +++ b/lib/core/utils/android_assistant_handler.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:io'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; @@ -139,6 +140,16 @@ class AndroidAssistantHandler { return; } + // Pre-warm socket connection before navigating to voice call. + // This reduces the chance of "websocket not connected" errors when + // opening voice call right after app start or from Android assistant. + final socketService = _ref.read(socketServiceProvider); + if (socketService != null && !socketService.isConnected) { + // Start connection attempt in parallel, don't wait for full connection + // The VoiceCallService.startCall() will wait with extended timeout + unawaited(socketService.connect()); + } + // Navigate to chat if not already there final isOnChatRoute = NavigationService.currentRoute == Routes.chat; if (!isOnChatRoute) { diff --git a/lib/features/chat/services/voice_call_service.dart b/lib/features/chat/services/voice_call_service.dart index 7c2df2f..c03afe7 100644 --- a/lib/features/chat/services/voice_call_service.dart +++ b/lib/features/chat/services/voice_call_service.dart @@ -158,11 +158,15 @@ class VoiceCallService { throw Exception('Preferred speech recognition engine is unavailable'); } - // Check microphone permissions - final hasMicPermission = await _voiceInput.checkPermissions(); + // Check and request microphone permissions if needed + var hasMicPermission = await _voiceInput.checkPermissions(); if (!hasMicPermission) { - _updateState(VoiceCallState.error); - throw Exception('Microphone permission not granted'); + // Try to request permission + hasMicPermission = await _voiceInput.requestMicrophonePermission(); + if (!hasMicPermission) { + _updateState(VoiceCallState.error); + throw Exception('Microphone permission not granted'); + } } // Initialize TTS with current app settings (engine/voice/rate/pitch/volume) @@ -309,11 +313,14 @@ class VoiceCallService { // Enable wake lock to keep screen on and prevent audio interruption await WakelockPlus.enable(); - // Ensure socket connection - await _socketService.ensureConnected(); + // Ensure socket connection with extended timeout for app startup scenarios. + // Default 2s is too short when app is launched from deep links/shortcuts. + final connected = await _socketService.ensureConnected( + timeout: const Duration(seconds: 10), + ); _sessionId = _socketService.sessionId; - if (_sessionId == null) { + if (!connected || _sessionId == null) { throw Exception('Failed to establish socket connection'); } diff --git a/lib/features/chat/services/voice_input_service.dart b/lib/features/chat/services/voice_input_service.dart index 40f1452..fa35575 100644 --- a/lib/features/chat/services/voice_input_service.dart +++ b/lib/features/chat/services/voice_input_service.dart @@ -1,8 +1,10 @@ import 'dart:async'; import 'dart:convert'; -import 'dart:io' show Platform; +import 'dart:io' show File, Platform; import 'dart:typed_data'; +import 'package:path_provider/path_provider.dart'; + import 'package:flutter/services.dart'; import 'package:flutter/widgets.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; @@ -343,6 +345,51 @@ class VoiceInputService { } } + /// Requests microphone permission if not already granted. + /// Returns true if permission is granted, false otherwise. + Future requestMicrophonePermission() async { + try { + // First check if we already have permission + var hasPermission = await _microphonePermissionProbe.hasPermission(); + if (hasPermission) return true; + + // The record package's start() method will trigger the system permission + // dialog if permission hasn't been granted yet. We start a brief recording + // and immediately stop it to trigger the permission request. + try { + // Create a temporary file path for the recording probe. + // An empty path only works on web; mobile platforms need a real path. + final tempDir = await getTemporaryDirectory(); + final tempPath = + '${tempDir.path}/mic_permission_probe_${DateTime.now().millisecondsSinceEpoch}.wav'; + + await _microphonePermissionProbe.start( + const RecordConfig(encoder: AudioEncoder.wav), + path: tempPath, + ); + await _microphonePermissionProbe.stop(); + + // Clean up the temporary file + try { + final tempFile = File(tempPath); + if (await tempFile.exists()) { + await tempFile.delete(); + } + } catch (_) { + // Ignore cleanup errors + } + } catch (_) { + // Starting may fail if permission is denied, which is expected + } + + // Check again after the permission request attempt + hasPermission = await _microphonePermissionProbe.hasPermission(); + return hasPermission; + } catch (_) { + return false; + } + } + Future _startLocalRecognition({ required bool allowOnlineFallback, }) async {