diff --git a/android/app/src/main/kotlin/app/cogwheel/conduit/BackgroundStreamingHandler.kt b/android/app/src/main/kotlin/app/cogwheel/conduit/BackgroundStreamingHandler.kt index a942e08..f3953c1 100644 --- a/android/app/src/main/kotlin/app/cogwheel/conduit/BackgroundStreamingHandler.kt +++ b/android/app/src/main/kotlin/app/cogwheel/conduit/BackgroundStreamingHandler.kt @@ -7,12 +7,13 @@ import android.app.PendingIntent import android.app.Service import android.content.Context import android.content.Intent -import android.content.SharedPreferences import android.content.pm.PackageManager import android.content.pm.ServiceInfo import android.Manifest import android.os.Build +import android.os.Handler import android.os.IBinder +import android.os.Looper import android.os.PowerManager import androidx.core.app.NotificationCompat import androidx.core.content.ContextCompat @@ -22,9 +23,24 @@ import io.flutter.plugin.common.MethodChannel import io.flutter.plugin.common.MethodChannel.MethodCallHandler import io.flutter.plugin.common.MethodChannel.Result import kotlinx.coroutines.* -import org.json.JSONArray -import org.json.JSONObject +/** + * Foreground service for keeping the app alive during streaming operations. + * + * This service provides reliable background execution on Android by: + * 1. Running as a foreground service with a notification (required by Android) + * 2. Acquiring a partial wake lock to prevent CPU sleep during active streaming + * 3. Supporting both dataSync and microphone foreground service types + * + * Key behaviors: + * - For chat streaming: Runs with dataSync type, acquires wake lock + * - For voice calls: Runs with microphone type (if permission granted), acquires wake lock + * - For socket keepalive: Runs with dataSync type, NO wake lock (CPU can sleep between pings) + * + * Android 14+ (UPSIDE_DOWN_CAKE) limitation: + * - dataSync foreground services are limited to 6 hours + * - We stop at 5 hours to provide a 1-hour buffer and notify the Flutter layer + */ class BackgroundStreamingService : Service() { private var wakeLock: PowerManager.WakeLock? = null private var activeStreamCount = 0 @@ -284,17 +300,23 @@ class BackgroundStreamingService : Service() { manager.createNotificationChannel(channel) } + private val wakeLockHandler = Handler(Looper.getMainLooper()) + private var wakeLockTimeoutRunnable: Runnable? = null + /** * Acquires a wake lock to prevent CPU sleep during active streaming. * - * Timeout is set to 6 minutes (360 seconds) to cover the 5-minute keepAlive - * interval with a 1-minute buffer. This ensures continuous wake lock coverage - * without gaps between refreshes. + * Timeout is set to 7 minutes (420 seconds) to cover the 5-minute keepAlive + * interval with a 2-minute buffer. This ensures continuous wake lock coverage + * even if the keepAlive timer drifts or is delayed by CPU throttling. * * Note: Android Play Console may flag wake locks > 1 minute as "excessive", * but continuous CPU availability is required for reliable streaming. * The alternative (60-second timeout with 5-minute refresh) creates 4-minute * gaps where the CPU can sleep, causing streams to stall. + * + * Uses setReferenceCounted(false) for deterministic single-holder semantics, + * with manual timeout handling via Handler to ensure proper cleanup. */ private fun acquireWakeLock() { if (wakeLock?.isHeld == true) return @@ -304,16 +326,29 @@ class BackgroundStreamingService : Service() { PowerManager.PARTIAL_WAKE_LOCK, "Conduit::StreamingWakeLock" ).apply { - // 6-minute timeout covers the 5-minute keepAlive interval + 1-minute buffer - // This ensures no gaps in wake lock coverage during active streaming - // Note: Use default reference-counted mode with timeout-based acquire - // (setReferenceCounted(false) interferes with timeout auto-release) - acquire(6 * 60 * 1000L) // 6 minutes - refreshed every 5 minutes by keepAlive() + // Disable reference counting for deterministic single-holder behavior + // This prevents accumulation if acquireWakeLock is called multiple times + setReferenceCounted(false) + acquire() } - println("BackgroundStreamingService: Wake lock acquired (6min timeout)") + + // Schedule manual timeout release (7 minutes) + // This replaces the acquire(timeout) approach which conflicts with setReferenceCounted(false) + wakeLockTimeoutRunnable?.let { wakeLockHandler.removeCallbacks(it) } + wakeLockTimeoutRunnable = Runnable { + println("BackgroundStreamingService: Wake lock timeout reached, releasing") + releaseWakeLock() + } + wakeLockHandler.postDelayed(wakeLockTimeoutRunnable!!, 7 * 60 * 1000L) + + println("BackgroundStreamingService: Wake lock acquired (7min manual timeout)") } private fun releaseWakeLock() { + // Cancel manual timeout handler + wakeLockTimeoutRunnable?.let { wakeLockHandler.removeCallbacks(it) } + wakeLockTimeoutRunnable = null + try { wakeLock?.let { if (it.isHeld) { @@ -322,7 +357,7 @@ class BackgroundStreamingService : Service() { } } } catch (e: Exception) { - // Wake lock may already be released due to timeout + // Wake lock may already be released println("BackgroundStreamingService: Wake lock release exception: ${e.message}") } wakeLock = null @@ -349,8 +384,8 @@ class BackgroundStreamingService : Service() { // activeStreamCount reflects user-visible streams (excludes socket-keepalive) if (activeStreamCount > 0) { // Refresh wake lock to maintain CPU availability for actual streaming. - // Wake lock has 6-minute timeout, keepAlive is called every 5 minutes, - // ensuring continuous coverage with 1-minute overlap buffer. + // Wake lock has 7-minute timeout, keepAlive is called every 5 minutes, + // ensuring continuous coverage with 2-minute overlap buffer. // Note: Foreground services prevent process termination but NOT CPU sleep. releaseWakeLock() acquireWakeLock() @@ -420,7 +455,6 @@ class BackgroundStreamingService : Service() { class BackgroundStreamingHandler(private val activity: MainActivity) : MethodCallHandler { private lateinit var channel: MethodChannel private lateinit var context: Context - private lateinit var sharedPrefs: SharedPreferences private val activeStreams = mutableSetOf() private val streamsRequiringMic = mutableSetOf() @@ -431,15 +465,12 @@ class BackgroundStreamingHandler(private val activity: MainActivity) : MethodCal companion object { private const val CHANNEL_NAME = "conduit/background_streaming" - private const val PREFS_NAME = "conduit_stream_states" - private const val STREAM_STATES_KEY = "active_streams" } fun setup(flutterEngine: FlutterEngine) { channel = MethodChannel(flutterEngine.dartExecutor.binaryMessenger, CHANNEL_NAME) channel.setMethodCallHandler(this) context = activity.applicationContext - sharedPrefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) createNotificationChannel() setupBroadcastReceiver() @@ -502,11 +533,14 @@ class BackgroundStreamingHandler(private val activity: MainActivity) : MethodCal addAction(BackgroundStreamingService.ACTION_MIC_PERMISSION_FALLBACK) } - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - context.registerReceiver(broadcastReceiver, filter, Context.RECEIVER_NOT_EXPORTED) - } else { - context.registerReceiver(broadcastReceiver, filter) - } + // Use ContextCompat.registerReceiver for unified handling across API levels + // RECEIVER_NOT_EXPORTED ensures security on all versions (internal broadcasts only) + ContextCompat.registerReceiver( + context, + broadcastReceiver, + filter, + ContextCompat.RECEIVER_NOT_EXPORTED + ) receiverRegistered = true } @@ -539,25 +573,22 @@ class BackgroundStreamingHandler(private val activity: MainActivity) : MethodCal result.success(null) } - "saveStreamStates" -> { - val states = call.argument>>("states") - val reason = call.argument("reason") - if (states != null) { - saveStreamStates(states, reason ?: "unknown") - result.success(null) - } else { - result.error("INVALID_ARGS", "States required", null) - } - } - - "recoverStreamStates" -> { - result.success(recoverStreamStates()) - } - "checkNotificationPermission" -> { result.success(hasNotificationPermission()) } + "getActiveStreamCount" -> { + // Return count for Flutter-native state reconciliation + result.success(activeStreams.size) + } + + "stopAllBackgroundExecution" -> { + // Stop all streams (used for reconciliation when orphaned service detected) + val allStreams = activeStreams.toList() + stopBackgroundExecution(allStreams) + result.success(null) + } + else -> { result.notImplemented() } @@ -713,72 +744,6 @@ class BackgroundStreamingHandler(private val activity: MainActivity) : MethodCal } } - private fun saveStreamStates(states: List>, reason: String) { - try { - val jsonArray = JSONArray() - for (state in states) { - val jsonObject = JSONObject() - for ((key, value) in state) { - jsonObject.put(key, value) - } - jsonArray.put(jsonObject) - } - - sharedPrefs.edit() - .putString(STREAM_STATES_KEY, jsonArray.toString()) - .putLong("saved_timestamp", System.currentTimeMillis()) - .putString("saved_reason", reason) - .apply() - - println("BackgroundStreamingHandler: Saved ${states.size} stream states (reason: $reason)") - } catch (e: Exception) { - println("BackgroundStreamingHandler: Failed to save stream states: ${e.message}") - } - } - - private fun recoverStreamStates(): List> { - return try { - val savedStates = sharedPrefs.getString(STREAM_STATES_KEY, null) ?: return emptyList() - val timestamp = sharedPrefs.getLong("saved_timestamp", 0) - val reason = sharedPrefs.getString("saved_reason", "unknown") - - // Check if states are not too old (max 1 hour) - val age = System.currentTimeMillis() - timestamp - if (age > 3600000) { // 1 hour in milliseconds - println("BackgroundStreamingHandler: Stream states too old (${age / 1000}s), discarding") - sharedPrefs.edit().remove(STREAM_STATES_KEY).apply() - return emptyList() - } - - val jsonArray = JSONArray(savedStates) - val result = mutableListOf>() - - for (i in 0 until jsonArray.length()) { - val jsonObject = jsonArray.getJSONObject(i) - val map = mutableMapOf() - - val keys = jsonObject.keys() - while (keys.hasNext()) { - val key = keys.next() - val value = jsonObject.get(key) - map[key] = value - } - - result.add(map) - } - - println("BackgroundStreamingHandler: Recovered ${result.size} stream states (reason: $reason, age: ${age / 1000}s)") - - // Clear saved states after recovery - sharedPrefs.edit().remove(STREAM_STATES_KEY).apply() - - result - } catch (e: Exception) { - println("BackgroundStreamingHandler: Failed to recover stream states: ${e.message}") - emptyList() - } - } - fun cleanup() { scope.cancel() stopBackgroundMonitoring() diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift index 95b1462..830f568 100644 --- a/ios/Runner/AppDelegate.swift +++ b/ios/Runner/AppDelegate.swift @@ -6,28 +6,77 @@ import UIKit import UniformTypeIdentifiers import WebKit +/// Manages AVAudioSession for voice calls in the background. +/// +/// IMPORTANT: This manager is ONLY used for server-side STT (speech-to-text). +/// When using local STT via speech_to_text plugin, that plugin manages its own +/// audio session. Do NOT activate this manager when local STT is in use to +/// avoid audio session conflicts. +/// +/// The voice_call_service.dart checks `useServerMic` before calling +/// startBackgroundExecution with requiresMicrophone:true. final class VoiceBackgroundAudioManager { static let shared = VoiceBackgroundAudioManager() private var isActive = false + private let lock = NSLock() + + /// Flag indicating another component (e.g., speech_to_text plugin) owns the audio session. + /// When true, this manager will skip activation to avoid conflicts. + private var externalSessionOwner = false private init() {} + + /// Mark that an external component (e.g., speech_to_text) is managing the audio session. + /// Call this before starting local STT to prevent conflicts. + func setExternalSessionOwner(_ isExternal: Bool) { + lock.lock() + defer { lock.unlock() } + externalSessionOwner = isExternal + + if isExternal { + print("VoiceBackgroundAudioManager: External session owner active, deferring to external management") + } + } + + /// Check if an external component owns the audio session. + var hasExternalSessionOwner: Bool { + lock.lock() + defer { lock.unlock() } + return externalSessionOwner + } func activate() { + lock.lock() + defer { lock.unlock() } + guard !isActive else { return } + + // Skip if another component is managing the audio session + if externalSessionOwner { + print("VoiceBackgroundAudioManager: Skipping activation - external session owner active") + return + } let session = AVAudioSession.sharedInstance() do { - try session.setCategory( - .playAndRecord, - mode: .voiceChat, - options: [ - .allowBluetooth, - .allowBluetoothA2DP, - .mixWithOthers, - .defaultToSpeaker, - ] - ) + // Check current category to avoid unnecessary reconfiguration + // This helps prevent conflicts if speech_to_text already configured the session + let currentCategory = session.category + let needsReconfiguration = currentCategory != .playAndRecord + + if needsReconfiguration { + try session.setCategory( + .playAndRecord, + mode: .voiceChat, + options: [ + .allowBluetooth, + .allowBluetoothA2DP, + .mixWithOthers, + .defaultToSpeaker, + ] + ) + } try session.setActive(true, options: .notifyOthersOnDeactivation) isActive = true } catch { @@ -36,7 +85,17 @@ final class VoiceBackgroundAudioManager { } func deactivate() { + lock.lock() + defer { lock.unlock() } + guard isActive else { return } + + // Don't deactivate if external owner - they manage their own lifecycle + if externalSessionOwner { + print("VoiceBackgroundAudioManager: Skipping deactivation - external session owner active") + isActive = false + return + } let session = AVAudioSession.sharedInstance() do { @@ -47,6 +106,13 @@ final class VoiceBackgroundAudioManager { isActive = false } + + /// Check if audio session is currently active (thread-safe). + var isSessionActive: Bool { + lock.lock() + defer { lock.unlock() } + return isActive + } } // Background streaming handler class @@ -87,6 +153,7 @@ class BackgroundStreamingHandler: NSObject { @objc private func appDidEnterBackground() { if !activeStreams.isEmpty { startBackgroundTask() + scheduleBGProcessingTask() } } @@ -119,17 +186,37 @@ class BackgroundStreamingHandler: NSObject { keepAlive() result(nil) - case "saveStreamStates": + case "checkBackgroundRefreshStatus": + // Check if background app refresh is enabled by the user + let status = UIApplication.shared.backgroundRefreshStatus + switch status { + case .available: + result(true) + case .denied, .restricted: + result(false) + @unknown default: + result(true) // Assume available for future cases + } + + case "setExternalAudioSessionOwner": + // Coordinate with speech_to_text plugin to prevent audio session conflicts if let args = call.arguments as? [String: Any], - let states = args["states"] as? [[String: Any]] { - saveStreamStates(states) + let isExternal = args["isExternal"] as? Bool { + VoiceBackgroundAudioManager.shared.setExternalSessionOwner(isExternal) result(nil) } else { - result(FlutterError(code: "INVALID_ARGS", message: "Invalid arguments", details: nil)) + result(FlutterError(code: "INVALID_ARGS", message: "Missing isExternal argument", details: nil)) } - case "recoverStreamStates": - result(recoverStreamStates()) + case "getActiveStreamCount": + // Return count for Flutter-native state reconciliation + result(activeStreams.count) + + case "stopAllBackgroundExecution": + // Stop all streams (used for reconciliation when orphaned service detected) + let allStreams = Array(activeStreams) + stopBackgroundExecution(streamIds: allStreams) + result(nil) default: result(FlutterMethodNotImplemented) @@ -137,16 +224,24 @@ class BackgroundStreamingHandler: NSObject { } private func startBackgroundExecution(streamIds: [String], requiresMic: Bool) { + // Add new stream IDs to active set activeStreams.formUnion(streamIds) + + // Clean up any mic streams that are no longer active (e.g., completed streams) + // This ensures microphoneStreams stays in sync with activeStreams microphoneStreams.formIntersection(activeStreams) + + // If these new streams require microphone, add them to the mic set if requiresMic { microphoneStreams.formUnion(streamIds) } + // Activate audio session for microphone access in background if !microphoneStreams.isEmpty { VoiceBackgroundAudioManager.shared.activate() } + // Start background tasks if app is already backgrounded if UIApplication.shared.applicationState == .background { startBackgroundTask() scheduleBGProcessingTask() @@ -171,7 +266,11 @@ class BackgroundStreamingHandler: NSObject { guard backgroundTask == .invalid else { return } backgroundTask = UIApplication.shared.beginBackgroundTask(withName: "ConduitStreaming") { [weak self] in - self?.endBackgroundTask() + guard let self = self else { return } + // Notify Flutter about streams being suspended before task expires + self.notifyStreamsSuspending(reason: "background_task_expiring") + self.channel?.invokeMethod("backgroundTaskExpiring", arguments: nil) + self.endBackgroundTask() } } @@ -183,40 +282,65 @@ class BackgroundStreamingHandler: NSObject { } private func keepAlive() { + // Use atomic task refresh: start new task before ending old one + // This prevents the brief window where iOS could suspend the app if backgroundTask != .invalid { - endBackgroundTask() + let oldTask = backgroundTask + + // Begin a new task BEFORE marking old one invalid + // This ensures continuous background execution coverage + let newTask = UIApplication.shared.beginBackgroundTask(withName: "ConduitStreaming") { [weak self] in + guard let self = self else { return } + self.notifyStreamsSuspending(reason: "keepalive_task_expiring") + self.channel?.invokeMethod("backgroundTaskExpiring", arguments: nil) + // End this specific task, not whatever is in backgroundTask + if self.backgroundTask != .invalid { + UIApplication.shared.endBackgroundTask(self.backgroundTask) + self.backgroundTask = .invalid + } + } + + // Only update state if we successfully got a new task + if newTask != .invalid { + backgroundTask = newTask + // Now safe to end old task + UIApplication.shared.endBackgroundTask(oldTask) + } + // If newTask is .invalid, keep the old task running (it's better than nothing) + } else if !activeStreams.isEmpty { + // No current task but we have active streams - start one startBackgroundTask() } + // Keep audio session active for microphone streams if !microphoneStreams.isEmpty { VoiceBackgroundAudioManager.shared.activate() } } - private func saveStreamStates(_ states: [[String: Any]]) { - do { - let jsonData = try JSONSerialization.data(withJSONObject: states, options: []) - UserDefaults.standard.set(jsonData, forKey: "ConduitActiveStreams") - } catch { - print("BackgroundStreamingHandler: Failed to serialize stream states: \(error)") - } - } - - private func recoverStreamStates() -> [[String: Any]] { - guard let jsonData = UserDefaults.standard.data(forKey: "ConduitActiveStreams") else { - return [] - } - do { - if let states = try JSONSerialization.jsonObject(with: jsonData, options: []) as? [[String: Any]] { - return states - } - } catch { - print("BackgroundStreamingHandler: Failed to deserialize stream states: \(error)") - } - return [] + private func notifyStreamsSuspending(reason: String) { + guard !activeStreams.isEmpty else { return } + channel?.invokeMethod("streamsSuspending", arguments: [ + "streamIds": Array(activeStreams), + "reason": reason + ]) } // MARK: - BGTaskScheduler Methods + // + // IMPORTANT: BGProcessingTask limitations on iOS: + // - iOS schedules these during opportunistic windows (device charging, overnight, etc.) + // - The earliestBeginDate is a HINT, not a guarantee of immediate execution + // - Typical execution time is ~1-3 minutes when granted, but may NOT run at all + // - BGProcessingTask is "best-effort bonus time", NOT "guaranteed extended execution" + // + // For reliable background execution: + // - Voice calls: UIBackgroundModes "audio" + AVAudioSession keeps app alive reliably + // - Chat streaming: beginBackgroundTask gives ~30 seconds (only reliable mechanism) + // - Socket keepalive: Best-effort; iOS may suspend app regardless + // + // The BGProcessingTask here provides opportunistic extended time for long-running + // streams, but callers should NOT depend on it for critical functionality. func registerBackgroundTasks() { BGTaskScheduler.shared.register( @@ -235,7 +359,9 @@ class BackgroundStreamingHandler: NSObject { request.requiresNetworkConnectivity = true request.requiresExternalPower = false - // Schedule for immediate execution when app backgrounds + // Request execution as soon as possible (best-effort only) + // WARNING: iOS heavily throttles BGProcessingTask - it may run hours later or not at all. + // This is supplementary to beginBackgroundTask, which is the primary mechanism. request.earliestBeginDate = Date(timeIntervalSinceNow: 1) do { @@ -262,9 +388,12 @@ class BackgroundStreamingHandler: NSObject { // Set expiration handler task.expirationHandler = { [weak self] in + guard let self = self else { return } print("BackgroundStreamingHandler: BGProcessingTask expiring") - self?.notifyTaskExpiring() - self?.bgProcessingTask = nil + // Notify Flutter about streams being suspended + self.notifyStreamsSuspending(reason: "bg_processing_task_expiring") + self.channel?.invokeMethod("backgroundTaskExpiring", arguments: nil) + self.bgProcessingTask = nil } // Notify Flutter that we have extended background time @@ -273,21 +402,23 @@ class BackgroundStreamingHandler: NSObject { "estimatedTime": 180 // ~3 minutes typical for BGProcessingTask ]) - // Keep task alive while streams are active - let workItem = DispatchWorkItem { [weak self] in - guard let self = self else { return } + // Keep task alive while streams are active using async Task + Task { [weak self] in + guard let self = self else { + task.setTaskCompleted(success: false) + return + } - // Keep sending keepAlive signals - let keepAliveInterval: TimeInterval = 30 + let keepAliveInterval: UInt64 = 30_000_000_000 // 30 seconds in nanoseconds var elapsedTime: TimeInterval = 0 let maxTime: TimeInterval = 180 // 3 minutes while !self.activeStreams.isEmpty && elapsedTime < maxTime { - Thread.sleep(forTimeInterval: keepAliveInterval) - elapsedTime += keepAliveInterval + try? await Task.sleep(nanoseconds: keepAliveInterval) + elapsedTime += 30 // Notify Flutter to keep streams alive - DispatchQueue.main.async { + await MainActor.run { self.channel?.invokeMethod("backgroundKeepAlive", arguments: nil) } } @@ -296,13 +427,8 @@ class BackgroundStreamingHandler: NSObject { task.setTaskCompleted(success: true) self.bgProcessingTask = nil } - - DispatchQueue.global(qos: .background).async(execute: workItem) } - private func notifyTaskExpiring() { - channel?.invokeMethod("backgroundTaskExpiring", arguments: nil) - } deinit { NotificationCenter.default.removeObserver(self) diff --git a/lib/core/providers/app_startup_providers.dart b/lib/core/providers/app_startup_providers.dart index 0910481..35d2320 100644 --- a/lib/core/providers/app_startup_providers.dart +++ b/lib/core/providers/app_startup_providers.dart @@ -132,6 +132,104 @@ void _scheduleConversationWarmup(Ref ref, {bool force = false}) { }); } +/// Initialize background streaming handler with error callbacks. +/// +/// This registers callbacks for platform events (service failures, time limits, etc.) +Future _initializeBackgroundStreaming(Ref ref) async { + try { + await BackgroundStreamingHandler.instance.initialize( + serviceFailedCallback: (error, errorType, streamIds) { + if (!ref.mounted) return; + DebugLogger.error( + 'background-service-failed', + scope: 'startup', + error: error, + data: {'type': errorType, 'streams': streamIds.length}, + ); + // Clear any streaming state in chat providers for failed streams + // The UI will show the partially completed message + }, + timeLimitApproachingCallback: (remainingMinutes) { + if (!ref.mounted) return; + DebugLogger.warning( + 'background-time-limit', + scope: 'startup', + data: {'remainingMinutes': remainingMinutes}, + ); + // Could show a notification to the user here + }, + microphonePermissionFallbackCallback: () { + if (!ref.mounted) return; + DebugLogger.warning('background-mic-fallback', scope: 'startup'); + // Microphone permission not granted, falling back to data sync only + }, + streamsSuspendingCallback: (streamIds) { + if (!ref.mounted) return; + DebugLogger.stream( + 'streams-suspending', + scope: 'startup', + data: {'count': streamIds.length}, + ); + }, + backgroundTaskExpiringCallback: () { + if (!ref.mounted) return; + DebugLogger.stream('background-task-expiring', scope: 'startup'); + }, + backgroundTaskExtendedCallback: (streamIds, estimatedSeconds) { + if (!ref.mounted) return; + DebugLogger.stream( + 'background-task-extended', + scope: 'startup', + data: {'count': streamIds.length, 'seconds': estimatedSeconds}, + ); + }, + backgroundKeepAliveCallback: () { + // Keep-alive signal received from platform + }, + ); + + if (!ref.mounted) return; + + // Check background refresh status on iOS and log warning if disabled + final bgRefreshEnabled = await BackgroundStreamingHandler.instance + .checkBackgroundRefreshStatus(); + + if (!ref.mounted) return; + + if (!bgRefreshEnabled) { + DebugLogger.warning( + 'background-refresh-disabled', + scope: 'startup', + data: { + 'message': + 'Background App Refresh is disabled. Background streaming may be limited.', + }, + ); + } + + // Check notification permission on Android 13+ and log warning if denied + // Without notification permission, foreground service runs silently without user awareness + final notificationPermission = await BackgroundStreamingHandler.instance + .checkNotificationPermission(); + + if (!ref.mounted) return; + + if (!notificationPermission) { + DebugLogger.warning( + 'notification-permission-denied', + scope: 'startup', + data: { + 'message': + 'Notification permission denied. Background streaming notifications will not be shown.', + }, + ); + } + } catch (e) { + if (!ref.mounted) return; + DebugLogger.error('background-init-failed', scope: 'startup', error: e); + } +} + /// App-level startup/background task flow orchestrator. /// /// Moves background initialization out of widgets and into a Riverpod controller, @@ -199,6 +297,12 @@ class AppStartupFlow extends _$AppStartupFlow { keepAlive(socketPersistenceProvider); }); + // Initialize background streaming handler with error callbacks + Future.delayed(const Duration(milliseconds: 64), () { + if (!ref.mounted) return; + _initializeBackgroundStreaming(ref); + }); + // Warm the conversations list in the background as soon as possible, // but avoid doing so on poor connectivity to reduce startup load. // Apply a small randomized delay to smooth load spikes across app wakes. @@ -467,29 +571,61 @@ class _SocketPersistenceObserver extends WidgetsBindingObserver { void _startBackground() { if (_bgActive) return; if (!_shouldKeepAlive()) return; - try { - BackgroundStreamingHandler.instance.startBackgroundExecution([_socketId]); - // Periodic keep-alive for iOS background task management. - // On Android, foreground service keeps app alive without frequent pings. - // 5-minute interval is sufficient and matches wakelock timeout buffer. - _heartbeat?.cancel(); - _heartbeat = Timer.periodic(const Duration(minutes: 5), (_) async { - try { - await BackgroundStreamingHandler.instance.keepAlive(); - } catch (_) {} - }); - _bgActive = true; - } catch (_) {} + + // Mark as active immediately to prevent duplicate attempts + _bgActive = true; + + BackgroundStreamingHandler.instance + .startBackgroundExecution([_socketId]) + .then((_) { + // Guard: if background was stopped while awaiting, don't create timer + if (!_bgActive) return; + + // Periodic keep-alive for iOS background task management. + // On Android, foreground service keeps app alive without frequent pings. + // 5-minute interval is sufficient and matches wakelock timeout buffer. + _heartbeat?.cancel(); + _heartbeat = Timer.periodic(const Duration(minutes: 5), (_) async { + final success = await BackgroundStreamingHandler.instance + .keepAlive(); + if (!success) { + DebugLogger.warning( + 'socket-keepalive-failed', + scope: 'background', + ); + // Keep-alive failed but don't stop - the service may still be running + } + }); + }) + .catchError((Object e) { + _bgActive = false; // Rollback on failure + DebugLogger.error( + 'socket-bg-start-failed', + scope: 'background', + error: e, + ); + }); } void _stopBackground() { if (!_bgActive) return; - try { - BackgroundStreamingHandler.instance.stopBackgroundExecution([_socketId]); - } catch (_) {} + + // Mark as inactive immediately to prevent race conditions + _bgActive = false; _heartbeat?.cancel(); _heartbeat = null; - _bgActive = false; + + // Fire-and-forget with proper error handling + // We don't await because lifecycle callbacks should return quickly + BackgroundStreamingHandler.instance + .stopBackgroundExecution([_socketId]) + .catchError((Object e) { + DebugLogger.error( + 'socket-bg-stop-failed', + scope: 'background', + error: e, + ); + }); } @override @@ -503,6 +639,9 @@ class _SocketPersistenceObserver extends WidgetsBindingObserver { case AppLifecycleState.resumed: _isBackgrounded = false; _stopBackground(); + // Reconcile background state on resume to detect orphaned services + // or stale Flutter state from native service crashes + _reconcileOnResume(); break; case AppLifecycleState.detached: case AppLifecycleState.hidden: @@ -512,6 +651,18 @@ class _SocketPersistenceObserver extends WidgetsBindingObserver { } } + void _reconcileOnResume() { + // Fire-and-forget reconciliation with error handling + BackgroundStreamingHandler.instance.reconcileState().catchError((Object e) { + DebugLogger.error( + 'socket-reconcile-failed', + scope: 'background', + error: e, + ); + return false; // Return false to satisfy Future type + }); + } + // Called when active conversation changes; only acts during background void onActiveConversationChanged() { if (!_isBackgrounded) return; diff --git a/lib/core/services/background_streaming_handler.dart b/lib/core/services/background_streaming_handler.dart index 44e1bb1..1849f4d 100644 --- a/lib/core/services/background_streaming_handler.dart +++ b/lib/core/services/background_streaming_handler.dart @@ -3,10 +3,45 @@ import 'dart:io'; import 'package:flutter/services.dart'; import '../utils/debug_logger.dart'; -/// Handles background streaming continuation for iOS and Android +/// Handles background streaming continuation for iOS and Android. /// -/// On iOS: Uses beginBackgroundTask (~30s) + BGTaskScheduler (~3+ minutes) -/// On Android: Uses foreground service notifications +/// This service keeps the app alive when streaming content in the background, +/// ensuring that chat responses, voice calls, and socket connections continue +/// even when the app is not in the foreground. +/// +/// ## Platform Implementations +/// +/// ### iOS +/// - Uses `beginBackgroundTask` for ~30 seconds of execution +/// - Uses `BGProcessingTask` for extended time (~1-3 minutes when granted) +/// - **Limitation**: iOS may not grant extended time; streams may be interrupted +/// - Audio mode (`UIBackgroundModes: audio`) provides reliable background for voice calls +/// +/// ### Android +/// - Uses foreground service with notification (reliable, can run for hours) +/// - Acquires wake lock to prevent CPU sleep during active streaming +/// - **Android 14+**: dataSync services limited to 6 hours (we stop at 5h with warning) +/// +/// ## Usage +/// +/// For most streaming operations, only [startBackgroundExecution] and +/// [stopBackgroundExecution] are needed: +/// +/// ```dart +/// // When streaming starts +/// await BackgroundStreamingHandler.instance.startBackgroundExecution(['stream-123']); +/// +/// // When streaming completes +/// await BackgroundStreamingHandler.instance.stopBackgroundExecution(['stream-123']); +/// ``` +/// +/// For extended background sessions (e.g., voice calls), call [keepAlive] periodically: +/// +/// ```dart +/// Timer.periodic(Duration(minutes: 5), (_) { +/// BackgroundStreamingHandler.instance.keepAlive(); +/// }); +/// ``` class BackgroundStreamingHandler { static const MethodChannel _channel = MethodChannel( 'conduit/background_streaming', @@ -25,7 +60,41 @@ class BackgroundStreamingHandler { } final Set _activeStreamIds = {}; - final Map _streamStates = {}; + final Set _microphoneStreamIds = {}; + bool _initialized = false; + + /// Initialize the background streaming handler with callbacks. + /// + /// This should be called once during app startup to register error and + /// event callbacks. + Future initialize({ + void Function(String error, String errorType, List streamIds)? + serviceFailedCallback, + void Function(int remainingMinutes)? timeLimitApproachingCallback, + void Function()? microphonePermissionFallbackCallback, + void Function(List streamIds)? streamsSuspendingCallback, + void Function()? backgroundTaskExpiringCallback, + void Function(List streamIds, int estimatedSeconds)? + backgroundTaskExtendedCallback, + void Function()? backgroundKeepAliveCallback, + }) async { + if (_initialized) { + DebugLogger.stream('already-initialized', scope: 'background'); + return; + } + _initialized = true; + + // Register callbacks + onServiceFailed = serviceFailedCallback; + onBackgroundTimeLimitApproaching = timeLimitApproachingCallback; + onMicrophonePermissionFallback = microphonePermissionFallbackCallback; + onStreamsSuspending = streamsSuspendingCallback; + onBackgroundTaskExpiring = backgroundTaskExpiringCallback; + onBackgroundTaskExtended = backgroundTaskExtendedCallback; + onBackgroundKeepAlive = backgroundKeepAliveCallback; + + DebugLogger.stream('initialized', scope: 'background'); + } /// Returns count of actual content streams (excludes socket keepalive). int get _userVisibleStreamCount => @@ -69,9 +138,6 @@ class BackgroundStreamingHandler { data: {'count': streamIds.length, 'reason': reason}, ); onStreamsSuspending?.call(streamIds); - - // Save stream states for recovery - await saveStreamStatesForRecovery(streamIds, reason); break; case 'backgroundTaskExpiring': @@ -120,7 +186,6 @@ class BackgroundStreamingHandler { // Clean up failed streams for (final streamId in streamIds) { _activeStreamIds.remove(streamId); - _streamStates.remove(streamId); } break; @@ -139,10 +204,7 @@ class BackgroundStreamingHandler { break; case 'microphonePermissionFallback': - DebugLogger.stream( - 'mic-permission-fallback', - scope: 'background', - ); + DebugLogger.stream('mic-permission-fallback', scope: 'background'); onMicrophonePermissionFallback?.call(); break; @@ -157,18 +219,24 @@ class BackgroundStreamingHandler { }) async { if (!Platform.isIOS && !Platform.isAndroid) return; - _activeStreamIds.addAll(streamIds); - try { await _channel.invokeMethod('startBackgroundExecution', { 'streamIds': streamIds, 'requiresMicrophone': requiresMicrophone, }); + // Only add to active streams after successful platform call + _activeStreamIds.addAll(streamIds); + + // Track which streams require microphone for reconciliation + if (requiresMicrophone) { + _microphoneStreamIds.addAll(streamIds); + } + DebugLogger.stream( 'start', scope: 'background', - data: {'count': streamIds.length}, + data: {'count': streamIds.length, 'mic': requiresMicrophone}, ); } catch (e) { DebugLogger.error( @@ -177,6 +245,8 @@ class BackgroundStreamingHandler { error: e, data: {'count': streamIds.length}, ); + // Re-throw so callers know the background execution failed + rethrow; } } @@ -184,20 +254,27 @@ class BackgroundStreamingHandler { Future stopBackgroundExecution(List streamIds) async { if (!Platform.isIOS && !Platform.isAndroid) return; - _activeStreamIds.removeAll(streamIds); - streamIds.forEach(_streamStates.remove); - try { await _channel.invokeMethod('stopBackgroundExecution', { 'streamIds': streamIds, }); + // Only remove from tracking after successful platform call + // to maintain state consistency between Flutter and native layers + _activeStreamIds.removeAll(streamIds); + _microphoneStreamIds.removeAll(streamIds); + DebugLogger.stream( 'stop', scope: 'background', data: {'count': streamIds.length}, ); } catch (e) { + // Still remove from local tracking on error - the platform may have + // already stopped, and keeping stale state causes issues + _activeStreamIds.removeAll(streamIds); + _microphoneStreamIds.removeAll(streamIds); + DebugLogger.error( 'stop-failed', scope: 'background', @@ -207,68 +284,18 @@ class BackgroundStreamingHandler { } } - /// Register a stream with its current state - void registerStream( - String streamId, { - required String conversationId, - required String messageId, - String? sessionId, - int? lastChunkSequence, - String? lastContent, - }) { - _streamStates[streamId] = StreamState( - streamId: streamId, - conversationId: conversationId, - messageId: messageId, - sessionId: sessionId, - lastChunkSequence: lastChunkSequence ?? 0, - lastContent: lastContent ?? '', - timestamp: DateTime.now(), - ); - - _activeStreamIds.add(streamId); - } - - /// Update stream state with new chunk - void updateStreamState( - String streamId, { - int? chunkSequence, - String? content, - String? appendedContent, - }) { - final state = _streamStates[streamId]; - if (state == null) return; - - _streamStates[streamId] = state.copyWith( - lastChunkSequence: chunkSequence ?? state.lastChunkSequence, - lastContent: appendedContent != null - ? (state.lastContent + appendedContent) - : (content ?? state.lastContent), - timestamp: DateTime.now(), - ); - } - - /// Unregister a stream when it completes - void unregisterStream(String streamId) { - _activeStreamIds.remove(streamId); - _streamStates.remove(streamId); - } - - /// Get current stream state for recovery - StreamState? getStreamState(String streamId) { - return _streamStates[streamId]; - } - /// Keep alive the background task /// /// On iOS: Refreshes background task to prevent early termination /// On Android: Refreshes wake lock to keep service running - Future keepAlive() async { - if (!Platform.isIOS && !Platform.isAndroid) return; + /// + /// Returns true if keep-alive succeeded, false otherwise. + Future keepAlive() async { + if (!Platform.isIOS && !Platform.isAndroid) return true; // Skip keep-alive if no active streams - this ensures Android's count // stays synchronized with Flutter's actual state - if (_activeStreamIds.isEmpty) return; + if (_activeStreamIds.isEmpty) return true; try { await _channel.invokeMethod('keepAlive', { @@ -277,8 +304,32 @@ class BackgroundStreamingHandler { 'streamCount': _userVisibleStreamCount, }); DebugLogger.stream('keepalive-success', scope: 'background'); + return true; } catch (e) { DebugLogger.error('keepalive-failed', scope: 'background', error: e); + return false; + } + } + + /// Check if background app refresh is enabled (iOS only). + /// + /// Returns true on Android or if iOS background refresh is available. + /// Returns false if iOS background refresh is disabled by user. + Future checkBackgroundRefreshStatus() async { + if (!Platform.isIOS) return true; + + try { + final bool? status = await _channel.invokeMethod( + 'checkBackgroundRefreshStatus', + ); + return status ?? true; + } catch (e) { + DebugLogger.error( + 'check-background-refresh-failed', + scope: 'background', + error: e, + ); + return true; // Assume available on error to not block functionality } } @@ -304,179 +355,97 @@ class BackgroundStreamingHandler { } } - /// Recover stream states from previous app session - Future> recoverStreamStates() async { - if (!Platform.isIOS && !Platform.isAndroid) return []; - - try { - final List? states = await _channel.invokeMethod( - 'recoverStreamStates', - ); - if (states == null) return []; - - final recovered = []; - for (final stateData in states) { - // Platform channels return Map, need to convert - final map = Map.from(stateData as Map); - final state = StreamState.fromMap(map); - if (state != null) { - recovered.add(state); - _streamStates[state.streamId] = state; - } - } - - DebugLogger.stream( - 'recovered', - scope: 'background', - data: {'count': recovered.length}, - ); - return recovered; - } catch (e) { - DebugLogger.error('recover-failed', scope: 'background', error: e); - return []; - } - } - - /// Save stream states for recovery after app restart - Future saveStreamStatesForRecovery( - List streamIds, - String reason, - ) async { - DebugLogger.stream( - 'saveStreamStatesForRecovery called', - scope: 'background', - data: { - 'streamIds': streamIds, - 'reason': reason, - 'statesCount': _streamStates.length, - }, - ); - - final statesToSave = streamIds - .map((id) => _streamStates[id]) - .where((state) => state != null) - .map((state) => state!.toMap()) - .toList(); - - DebugLogger.stream( - 'statesToSave prepared', - scope: 'background', - data: {'count': statesToSave.length}, - ); - - try { - await _channel.invokeMethod('saveStreamStates', { - 'states': statesToSave, - 'reason': reason, - }); - DebugLogger.stream( - 'save-states-success', - scope: 'background', - data: {'count': statesToSave.length, 'reason': reason}, - ); - } catch (e) { - DebugLogger.error( - 'save-states-failed', - scope: 'background', - error: e, - data: {'count': streamIds.length, 'reason': reason}, - ); - } - } - /// Check if any streams are currently active bool get hasActiveStreams => _activeStreamIds.isNotEmpty; /// Get list of active stream IDs List get activeStreamIds => _activeStreamIds.toList(); - /// Clear all stream data (usually on app termination) - void clearAll() { - _activeStreamIds.clear(); - _streamStates.clear(); - } -} + /// Notify the native layer that an external component (e.g., speech_to_text + /// plugin) is managing the audio session. + /// + /// On iOS, this prevents VoiceBackgroundAudioManager from conflicting with + /// the speech_to_text plugin's audio session management. + /// On Android, this is a no-op as audio session management is different. + Future setExternalAudioSessionOwner(bool isExternal) async { + if (!Platform.isIOS) return; -/// Represents the state of a streaming request -class StreamState { - final String streamId; - final String conversationId; - final String messageId; - final String? sessionId; - final int lastChunkSequence; - final String lastContent; - final DateTime timestamp; - - const StreamState({ - required this.streamId, - required this.conversationId, - required this.messageId, - this.sessionId, - required this.lastChunkSequence, - required this.lastContent, - required this.timestamp, - }); - - StreamState copyWith({ - String? streamId, - String? conversationId, - String? messageId, - String? sessionId, - int? lastChunkSequence, - String? lastContent, - DateTime? timestamp, - }) { - return StreamState( - streamId: streamId ?? this.streamId, - conversationId: conversationId ?? this.conversationId, - messageId: messageId ?? this.messageId, - sessionId: sessionId ?? this.sessionId, - lastChunkSequence: lastChunkSequence ?? this.lastChunkSequence, - lastContent: lastContent ?? this.lastContent, - timestamp: timestamp ?? this.timestamp, - ); - } - - Map toMap() { - return { - 'streamId': streamId, - 'conversationId': conversationId, - 'messageId': messageId, - 'sessionId': sessionId, - 'lastChunkSequence': lastChunkSequence, - 'lastContent': lastContent, - 'timestamp': timestamp.millisecondsSinceEpoch, - }; - } - - static StreamState? fromMap(Map map) { try { - return StreamState( - streamId: map['streamId'] as String, - conversationId: map['conversationId'] as String, - messageId: map['messageId'] as String, - sessionId: map['sessionId'] as String?, - lastChunkSequence: map['lastChunkSequence'] as int? ?? 0, - lastContent: map['lastContent'] as String? ?? '', - timestamp: DateTime.fromMillisecondsSinceEpoch( - map['timestamp'] as int? ?? DateTime.now().millisecondsSinceEpoch, - ), + await _channel.invokeMethod('setExternalAudioSessionOwner', { + 'isExternal': isExternal, + }); + DebugLogger.stream( + isExternal + ? 'external-audio-owner-set' + : 'external-audio-owner-cleared', + scope: 'background', ); } catch (e) { - DebugLogger.error('parse-failed', scope: 'background', error: e); - return null; + DebugLogger.error( + 'set-external-audio-owner-failed', + scope: 'background', + error: e, + ); } } - /// Check if this state is stale (older than threshold) - bool isStale({Duration threshold = const Duration(minutes: 5)}) { - return DateTime.now().difference(timestamp) > threshold; + /// Clear all stream data (usually on app termination) + void clearAll() { + _activeStreamIds.clear(); + _microphoneStreamIds.clear(); } - @override - String toString() { - return 'StreamState(streamId: $streamId, conversationId: $conversationId, ' - 'messageId: $messageId, sequence: $lastChunkSequence, ' - 'contentLength: ${lastContent.length}, timestamp: $timestamp)'; + /// Reconcile Flutter state with native platform state. + /// + /// This should be called on app resume to detect and fix state drift + /// caused by native service crashes or other edge cases. Returns true + /// if reconciliation was needed and performed. + Future reconcileState() async { + if (!Platform.isIOS && !Platform.isAndroid) return false; + + try { + final int? nativeCount = await _channel.invokeMethod( + 'getActiveStreamCount', + ); + + if (nativeCount == null) return false; + + // If native has streams but Flutter doesn't, the native service is orphaned + if (nativeCount > 0 && _activeStreamIds.isEmpty) { + DebugLogger.warning( + 'reconcile-orphaned-service', + scope: 'background', + data: {'nativeCount': nativeCount}, + ); + // Stop the orphaned native service + await _channel.invokeMethod('stopAllBackgroundExecution'); + return true; + } + + // If Flutter has streams but native doesn't, restart the service + if (_activeStreamIds.isNotEmpty && nativeCount == 0) { + // Preserve microphone requirement from tracked streams + final requiresMicrophone = _microphoneStreamIds.isNotEmpty; + DebugLogger.warning( + 'reconcile-restart-service', + scope: 'background', + data: { + 'flutterCount': _activeStreamIds.length, + 'requiresMic': requiresMicrophone, + }, + ); + // Restart background execution for active streams with preserved capabilities + await _channel.invokeMethod('startBackgroundExecution', { + 'streamIds': _activeStreamIds.toList(), + 'requiresMicrophone': requiresMicrophone, + }); + return true; + } + + return false; + } catch (e) { + DebugLogger.error('reconcile-failed', scope: 'background', error: e); + return false; + } } } diff --git a/lib/core/services/streaming_helper.dart b/lib/core/services/streaming_helper.dart index 827b9fa..de05d64 100644 --- a/lib/core/services/streaming_helper.dart +++ b/lib/core/services/streaming_helper.dart @@ -1,5 +1,6 @@ import 'dart:async'; import 'dart:convert'; +import 'dart:io'; import 'package:flutter/material.dart'; @@ -7,6 +8,7 @@ import '../../core/models/chat_message.dart'; import '../../core/models/socket_event.dart'; import '../../core/services/socket_service.dart'; import '../../core/utils/tool_calls_parser.dart'; +import 'background_streaming_handler.dart'; import 'navigation_service.dart'; import 'conversation_delta_listener.dart'; import '../../shared/widgets/themed_dialogs.dart'; @@ -219,11 +221,41 @@ ActiveSocketStream attachUnifiedChunkedStreaming({ // Track if streaming has been finished to avoid duplicate cleanup bool hasFinished = false; - // Wrap finishStreaming to always clear the cancel token + // Start background execution to keep app alive during streaming (iOS/Android) + // Uses the assistantMessageId as a unique stream identifier + final streamId = 'chat-stream-$assistantMessageId'; + if (Platform.isIOS || Platform.isAndroid) { + // Fire-and-forget: background execution is best-effort and shouldn't block streaming + BackgroundStreamingHandler.instance + .startBackgroundExecution([streamId]) + .catchError((Object e) { + DebugLogger.error( + 'background-start-failed', + scope: 'streaming/helper', + error: e, + ); + }); + } + + // Wrap finishStreaming to always clear the cancel token and stop background execution void wrappedFinishStreaming() { if (hasFinished) return; hasFinished = true; api.clearStreamCancelToken(assistantMessageId); + + // Stop background execution when streaming completes + if (Platform.isIOS || Platform.isAndroid) { + BackgroundStreamingHandler.instance + .stopBackgroundExecution([streamId]) + .catchError((Object e) { + DebugLogger.error( + 'background-stop-failed', + scope: 'streaming/helper', + error: e, + ); + }); + } + finishStreaming(); } diff --git a/lib/features/chat/services/voice_call_service.dart b/lib/features/chat/services/voice_call_service.dart index c03afe7..388634e 100644 --- a/lib/features/chat/services/voice_call_service.dart +++ b/lib/features/chat/services/voice_call_service.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'dart:collection'; import 'dart:developer' as developer; +import 'dart:io'; import 'package:audioplayers/audioplayers.dart'; import 'package:flutter_callkit_incoming/entities/call_event.dart'; @@ -327,20 +328,34 @@ class VoiceCallService { // Initialize voice input first so we know which STT mode will be used await _voiceInput.initialize(); - // Only activate VoiceBackgroundAudioManager for server STT - // For local STT, speech_to_text handles its own iOS audio session + // Determine if we need microphone foreground service type. + // On Android 14+, FOREGROUND_SERVICE_TYPE_MICROPHONE is required for any + // background microphone access - including speech_to_text's local STT. + // On iOS, speech_to_text handles its own audio session so we only need + // the microphone type for server STT (which uses our VAD recorder). final useServerMic = (_voiceInput.prefersServerOnly && _voiceInput.hasServerStt) || (!_voiceInput.hasLocalStt && _voiceInput.hasServerStt); + final requiresMicrophone = Platform.isAndroid || useServerMic; await BackgroundStreamingHandler.instance.startBackgroundExecution(const [ _voiceCallStreamId, - ], requiresMicrophone: useServerMic); + ], requiresMicrophone: requiresMicrophone); // Set up periodic keep-alive to refresh wake lock (every 5 minutes) _keepAliveTimer?.cancel(); _keepAliveTimer = Timer.periodic( const Duration(minutes: 5), - (_) => BackgroundStreamingHandler.instance.keepAlive(), + (_) async { + final success = await BackgroundStreamingHandler.instance.keepAlive(); + if (!success) { + // Keep-alive failed but don't stop the call - service may still work + developer.log( + 'Voice call keep-alive failed', + name: 'VoiceCallService', + level: 900, // WARNING + ); + } + }, ); // Set up socket event listener for assistant responses