feat(voice-call): Improve socket connection and mic permission handling

This commit is contained in:
cogwheel0
2025-11-29 13:30:31 +05:30
parent 24bf1f06cb
commit 2ef49a2974
4 changed files with 83 additions and 8 deletions

View File

@@ -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.

View File

@@ -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) {

View File

@@ -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');
}

View File

@@ -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<bool> 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<void> _startLocalRecognition({
required bool allowOnlineFallback,
}) async {