fix(background): Improve background streaming reliability and error handling

feat(background): Add initialization and error handling for background streaming
feat(background): Improve background streaming reliability and error handling
feat(background): Improve iOS background task and stream management
refactor(android): Remove unused stream state persistence logic
feat(android): Improve wake lock and broadcast receiver handling
This commit is contained in:
cogwheel
2025-12-20 22:10:28 +05:30
parent 671b953f23
commit 6a07855c9b
6 changed files with 672 additions and 414 deletions

View File

@@ -7,12 +7,13 @@ import android.app.PendingIntent
import android.app.Service import android.app.Service
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.SharedPreferences
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.content.pm.ServiceInfo import android.content.pm.ServiceInfo
import android.Manifest import android.Manifest
import android.os.Build import android.os.Build
import android.os.Handler
import android.os.IBinder import android.os.IBinder
import android.os.Looper
import android.os.PowerManager import android.os.PowerManager
import androidx.core.app.NotificationCompat import androidx.core.app.NotificationCompat
import androidx.core.content.ContextCompat 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.MethodCallHandler
import io.flutter.plugin.common.MethodChannel.Result import io.flutter.plugin.common.MethodChannel.Result
import kotlinx.coroutines.* 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() { class BackgroundStreamingService : Service() {
private var wakeLock: PowerManager.WakeLock? = null private var wakeLock: PowerManager.WakeLock? = null
private var activeStreamCount = 0 private var activeStreamCount = 0
@@ -284,17 +300,23 @@ class BackgroundStreamingService : Service() {
manager.createNotificationChannel(channel) 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. * 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 * Timeout is set to 7 minutes (420 seconds) to cover the 5-minute keepAlive
* interval with a 1-minute buffer. This ensures continuous wake lock coverage * interval with a 2-minute buffer. This ensures continuous wake lock coverage
* without gaps between refreshes. * even if the keepAlive timer drifts or is delayed by CPU throttling.
* *
* Note: Android Play Console may flag wake locks > 1 minute as "excessive", * Note: Android Play Console may flag wake locks > 1 minute as "excessive",
* but continuous CPU availability is required for reliable streaming. * but continuous CPU availability is required for reliable streaming.
* The alternative (60-second timeout with 5-minute refresh) creates 4-minute * The alternative (60-second timeout with 5-minute refresh) creates 4-minute
* gaps where the CPU can sleep, causing streams to stall. * 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() { private fun acquireWakeLock() {
if (wakeLock?.isHeld == true) return if (wakeLock?.isHeld == true) return
@@ -304,16 +326,29 @@ class BackgroundStreamingService : Service() {
PowerManager.PARTIAL_WAKE_LOCK, PowerManager.PARTIAL_WAKE_LOCK,
"Conduit::StreamingWakeLock" "Conduit::StreamingWakeLock"
).apply { ).apply {
// 6-minute timeout covers the 5-minute keepAlive interval + 1-minute buffer // Disable reference counting for deterministic single-holder behavior
// This ensures no gaps in wake lock coverage during active streaming // This prevents accumulation if acquireWakeLock is called multiple times
// Note: Use default reference-counted mode with timeout-based acquire setReferenceCounted(false)
// (setReferenceCounted(false) interferes with timeout auto-release) acquire()
acquire(6 * 60 * 1000L) // 6 minutes - refreshed every 5 minutes by keepAlive()
} }
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() { private fun releaseWakeLock() {
// Cancel manual timeout handler
wakeLockTimeoutRunnable?.let { wakeLockHandler.removeCallbacks(it) }
wakeLockTimeoutRunnable = null
try { try {
wakeLock?.let { wakeLock?.let {
if (it.isHeld) { if (it.isHeld) {
@@ -322,7 +357,7 @@ class BackgroundStreamingService : Service() {
} }
} }
} catch (e: Exception) { } 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}") println("BackgroundStreamingService: Wake lock release exception: ${e.message}")
} }
wakeLock = null wakeLock = null
@@ -349,8 +384,8 @@ class BackgroundStreamingService : Service() {
// activeStreamCount reflects user-visible streams (excludes socket-keepalive) // activeStreamCount reflects user-visible streams (excludes socket-keepalive)
if (activeStreamCount > 0) { if (activeStreamCount > 0) {
// Refresh wake lock to maintain CPU availability for actual streaming. // Refresh wake lock to maintain CPU availability for actual streaming.
// Wake lock has 6-minute timeout, keepAlive is called every 5 minutes, // Wake lock has 7-minute timeout, keepAlive is called every 5 minutes,
// ensuring continuous coverage with 1-minute overlap buffer. // ensuring continuous coverage with 2-minute overlap buffer.
// Note: Foreground services prevent process termination but NOT CPU sleep. // Note: Foreground services prevent process termination but NOT CPU sleep.
releaseWakeLock() releaseWakeLock()
acquireWakeLock() acquireWakeLock()
@@ -420,7 +455,6 @@ class BackgroundStreamingService : Service() {
class BackgroundStreamingHandler(private val activity: MainActivity) : MethodCallHandler { class BackgroundStreamingHandler(private val activity: MainActivity) : MethodCallHandler {
private lateinit var channel: MethodChannel private lateinit var channel: MethodChannel
private lateinit var context: Context private lateinit var context: Context
private lateinit var sharedPrefs: SharedPreferences
private val activeStreams = mutableSetOf<String>() private val activeStreams = mutableSetOf<String>()
private val streamsRequiringMic = mutableSetOf<String>() private val streamsRequiringMic = mutableSetOf<String>()
@@ -431,15 +465,12 @@ class BackgroundStreamingHandler(private val activity: MainActivity) : MethodCal
companion object { companion object {
private const val CHANNEL_NAME = "conduit/background_streaming" 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) { fun setup(flutterEngine: FlutterEngine) {
channel = MethodChannel(flutterEngine.dartExecutor.binaryMessenger, CHANNEL_NAME) channel = MethodChannel(flutterEngine.dartExecutor.binaryMessenger, CHANNEL_NAME)
channel.setMethodCallHandler(this) channel.setMethodCallHandler(this)
context = activity.applicationContext context = activity.applicationContext
sharedPrefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
createNotificationChannel() createNotificationChannel()
setupBroadcastReceiver() setupBroadcastReceiver()
@@ -502,11 +533,14 @@ class BackgroundStreamingHandler(private val activity: MainActivity) : MethodCal
addAction(BackgroundStreamingService.ACTION_MIC_PERMISSION_FALLBACK) addAction(BackgroundStreamingService.ACTION_MIC_PERMISSION_FALLBACK)
} }
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { // Use ContextCompat.registerReceiver for unified handling across API levels
context.registerReceiver(broadcastReceiver, filter, Context.RECEIVER_NOT_EXPORTED) // RECEIVER_NOT_EXPORTED ensures security on all versions (internal broadcasts only)
} else { ContextCompat.registerReceiver(
context.registerReceiver(broadcastReceiver, filter) context,
} broadcastReceiver,
filter,
ContextCompat.RECEIVER_NOT_EXPORTED
)
receiverRegistered = true receiverRegistered = true
} }
@@ -539,25 +573,22 @@ class BackgroundStreamingHandler(private val activity: MainActivity) : MethodCal
result.success(null) result.success(null)
} }
"saveStreamStates" -> {
val states = call.argument<List<Map<String, Any>>>("states")
val reason = call.argument<String>("reason")
if (states != null) {
saveStreamStates(states, reason ?: "unknown")
result.success(null)
} else {
result.error("INVALID_ARGS", "States required", null)
}
}
"recoverStreamStates" -> {
result.success(recoverStreamStates())
}
"checkNotificationPermission" -> { "checkNotificationPermission" -> {
result.success(hasNotificationPermission()) 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 -> { else -> {
result.notImplemented() result.notImplemented()
} }
@@ -713,72 +744,6 @@ class BackgroundStreamingHandler(private val activity: MainActivity) : MethodCal
} }
} }
private fun saveStreamStates(states: List<Map<String, Any>>, 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<Map<String, Any>> {
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<Map<String, Any>>()
for (i in 0 until jsonArray.length()) {
val jsonObject = jsonArray.getJSONObject(i)
val map = mutableMapOf<String, Any>()
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() { fun cleanup() {
scope.cancel() scope.cancel()
stopBackgroundMonitoring() stopBackgroundMonitoring()

View File

@@ -6,18 +6,66 @@ import UIKit
import UniformTypeIdentifiers import UniformTypeIdentifiers
import WebKit 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 { final class VoiceBackgroundAudioManager {
static let shared = VoiceBackgroundAudioManager() static let shared = VoiceBackgroundAudioManager()
private var isActive = false 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() {} 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() { func activate() {
lock.lock()
defer { lock.unlock() }
guard !isActive else { return } 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() let session = AVAudioSession.sharedInstance()
do { do {
// 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( try session.setCategory(
.playAndRecord, .playAndRecord,
mode: .voiceChat, mode: .voiceChat,
@@ -28,6 +76,7 @@ final class VoiceBackgroundAudioManager {
.defaultToSpeaker, .defaultToSpeaker,
] ]
) )
}
try session.setActive(true, options: .notifyOthersOnDeactivation) try session.setActive(true, options: .notifyOthersOnDeactivation)
isActive = true isActive = true
} catch { } catch {
@@ -36,8 +85,18 @@ final class VoiceBackgroundAudioManager {
} }
func deactivate() { func deactivate() {
lock.lock()
defer { lock.unlock() }
guard isActive else { return } 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() let session = AVAudioSession.sharedInstance()
do { do {
try session.setActive(false, options: .notifyOthersOnDeactivation) try session.setActive(false, options: .notifyOthersOnDeactivation)
@@ -47,6 +106,13 @@ final class VoiceBackgroundAudioManager {
isActive = false 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 // Background streaming handler class
@@ -87,6 +153,7 @@ class BackgroundStreamingHandler: NSObject {
@objc private func appDidEnterBackground() { @objc private func appDidEnterBackground() {
if !activeStreams.isEmpty { if !activeStreams.isEmpty {
startBackgroundTask() startBackgroundTask()
scheduleBGProcessingTask()
} }
} }
@@ -119,17 +186,37 @@ class BackgroundStreamingHandler: NSObject {
keepAlive() keepAlive()
result(nil) result(nil)
case "saveStreamStates": case "checkBackgroundRefreshStatus":
if let args = call.arguments as? [String: Any], // Check if background app refresh is enabled by the user
let states = args["states"] as? [[String: Any]] { let status = UIApplication.shared.backgroundRefreshStatus
saveStreamStates(states) switch status {
result(nil) case .available:
} else { result(true)
result(FlutterError(code: "INVALID_ARGS", message: "Invalid arguments", details: nil)) case .denied, .restricted:
result(false)
@unknown default:
result(true) // Assume available for future cases
} }
case "recoverStreamStates": case "setExternalAudioSessionOwner":
result(recoverStreamStates()) // Coordinate with speech_to_text plugin to prevent audio session conflicts
if let args = call.arguments as? [String: Any],
let isExternal = args["isExternal"] as? Bool {
VoiceBackgroundAudioManager.shared.setExternalSessionOwner(isExternal)
result(nil)
} else {
result(FlutterError(code: "INVALID_ARGS", message: "Missing isExternal argument", details: nil))
}
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: default:
result(FlutterMethodNotImplemented) result(FlutterMethodNotImplemented)
@@ -137,16 +224,24 @@ class BackgroundStreamingHandler: NSObject {
} }
private func startBackgroundExecution(streamIds: [String], requiresMic: Bool) { private func startBackgroundExecution(streamIds: [String], requiresMic: Bool) {
// Add new stream IDs to active set
activeStreams.formUnion(streamIds) 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) microphoneStreams.formIntersection(activeStreams)
// If these new streams require microphone, add them to the mic set
if requiresMic { if requiresMic {
microphoneStreams.formUnion(streamIds) microphoneStreams.formUnion(streamIds)
} }
// Activate audio session for microphone access in background
if !microphoneStreams.isEmpty { if !microphoneStreams.isEmpty {
VoiceBackgroundAudioManager.shared.activate() VoiceBackgroundAudioManager.shared.activate()
} }
// Start background tasks if app is already backgrounded
if UIApplication.shared.applicationState == .background { if UIApplication.shared.applicationState == .background {
startBackgroundTask() startBackgroundTask()
scheduleBGProcessingTask() scheduleBGProcessingTask()
@@ -171,7 +266,11 @@ class BackgroundStreamingHandler: NSObject {
guard backgroundTask == .invalid else { return } guard backgroundTask == .invalid else { return }
backgroundTask = UIApplication.shared.beginBackgroundTask(withName: "ConduitStreaming") { [weak self] in 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() { 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 { 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() startBackgroundTask()
} }
// Keep audio session active for microphone streams
if !microphoneStreams.isEmpty { if !microphoneStreams.isEmpty {
VoiceBackgroundAudioManager.shared.activate() VoiceBackgroundAudioManager.shared.activate()
} }
} }
private func saveStreamStates(_ states: [[String: Any]]) { private func notifyStreamsSuspending(reason: String) {
do { guard !activeStreams.isEmpty else { return }
let jsonData = try JSONSerialization.data(withJSONObject: states, options: []) channel?.invokeMethod("streamsSuspending", arguments: [
UserDefaults.standard.set(jsonData, forKey: "ConduitActiveStreams") "streamIds": Array(activeStreams),
} catch { "reason": reason
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 []
} }
// MARK: - BGTaskScheduler Methods // 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() { func registerBackgroundTasks() {
BGTaskScheduler.shared.register( BGTaskScheduler.shared.register(
@@ -235,7 +359,9 @@ class BackgroundStreamingHandler: NSObject {
request.requiresNetworkConnectivity = true request.requiresNetworkConnectivity = true
request.requiresExternalPower = false 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) request.earliestBeginDate = Date(timeIntervalSinceNow: 1)
do { do {
@@ -262,9 +388,12 @@ class BackgroundStreamingHandler: NSObject {
// Set expiration handler // Set expiration handler
task.expirationHandler = { [weak self] in task.expirationHandler = { [weak self] in
guard let self = self else { return }
print("BackgroundStreamingHandler: BGProcessingTask expiring") print("BackgroundStreamingHandler: BGProcessingTask expiring")
self?.notifyTaskExpiring() // Notify Flutter about streams being suspended
self?.bgProcessingTask = nil self.notifyStreamsSuspending(reason: "bg_processing_task_expiring")
self.channel?.invokeMethod("backgroundTaskExpiring", arguments: nil)
self.bgProcessingTask = nil
} }
// Notify Flutter that we have extended background time // Notify Flutter that we have extended background time
@@ -273,21 +402,23 @@ class BackgroundStreamingHandler: NSObject {
"estimatedTime": 180 // ~3 minutes typical for BGProcessingTask "estimatedTime": 180 // ~3 minutes typical for BGProcessingTask
]) ])
// Keep task alive while streams are active // Keep task alive while streams are active using async Task
let workItem = DispatchWorkItem { [weak self] in Task { [weak self] in
guard let self = self else { return } guard let self = self else {
task.setTaskCompleted(success: false)
return
}
// Keep sending keepAlive signals let keepAliveInterval: UInt64 = 30_000_000_000 // 30 seconds in nanoseconds
let keepAliveInterval: TimeInterval = 30
var elapsedTime: TimeInterval = 0 var elapsedTime: TimeInterval = 0
let maxTime: TimeInterval = 180 // 3 minutes let maxTime: TimeInterval = 180 // 3 minutes
while !self.activeStreams.isEmpty && elapsedTime < maxTime { while !self.activeStreams.isEmpty && elapsedTime < maxTime {
Thread.sleep(forTimeInterval: keepAliveInterval) try? await Task.sleep(nanoseconds: keepAliveInterval)
elapsedTime += keepAliveInterval elapsedTime += 30
// Notify Flutter to keep streams alive // Notify Flutter to keep streams alive
DispatchQueue.main.async { await MainActor.run {
self.channel?.invokeMethod("backgroundKeepAlive", arguments: nil) self.channel?.invokeMethod("backgroundKeepAlive", arguments: nil)
} }
} }
@@ -296,13 +427,8 @@ class BackgroundStreamingHandler: NSObject {
task.setTaskCompleted(success: true) task.setTaskCompleted(success: true)
self.bgProcessingTask = nil self.bgProcessingTask = nil
} }
DispatchQueue.global(qos: .background).async(execute: workItem)
} }
private func notifyTaskExpiring() {
channel?.invokeMethod("backgroundTaskExpiring", arguments: nil)
}
deinit { deinit {
NotificationCenter.default.removeObserver(self) NotificationCenter.default.removeObserver(self)

View File

@@ -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<void> _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. /// App-level startup/background task flow orchestrator.
/// ///
/// Moves background initialization out of widgets and into a Riverpod controller, /// Moves background initialization out of widgets and into a Riverpod controller,
@@ -199,6 +297,12 @@ class AppStartupFlow extends _$AppStartupFlow {
keepAlive(socketPersistenceProvider); keepAlive(socketPersistenceProvider);
}); });
// Initialize background streaming handler with error callbacks
Future<void>.delayed(const Duration(milliseconds: 64), () {
if (!ref.mounted) return;
_initializeBackgroundStreaming(ref);
});
// Warm the conversations list in the background as soon as possible, // Warm the conversations list in the background as soon as possible,
// but avoid doing so on poor connectivity to reduce startup load. // but avoid doing so on poor connectivity to reduce startup load.
// Apply a small randomized delay to smooth load spikes across app wakes. // Apply a small randomized delay to smooth load spikes across app wakes.
@@ -467,29 +571,61 @@ class _SocketPersistenceObserver extends WidgetsBindingObserver {
void _startBackground() { void _startBackground() {
if (_bgActive) return; if (_bgActive) return;
if (!_shouldKeepAlive()) return; if (!_shouldKeepAlive()) return;
try {
BackgroundStreamingHandler.instance.startBackgroundExecution([_socketId]); // 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. // Periodic keep-alive for iOS background task management.
// On Android, foreground service keeps app alive without frequent pings. // On Android, foreground service keeps app alive without frequent pings.
// 5-minute interval is sufficient and matches wakelock timeout buffer. // 5-minute interval is sufficient and matches wakelock timeout buffer.
_heartbeat?.cancel(); _heartbeat?.cancel();
_heartbeat = Timer.periodic(const Duration(minutes: 5), (_) async { _heartbeat = Timer.periodic(const Duration(minutes: 5), (_) async {
try { final success = await BackgroundStreamingHandler.instance
await BackgroundStreamingHandler.instance.keepAlive(); .keepAlive();
} catch (_) {} 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,
);
}); });
_bgActive = true;
} catch (_) {}
} }
void _stopBackground() { void _stopBackground() {
if (!_bgActive) return; if (!_bgActive) return;
try {
BackgroundStreamingHandler.instance.stopBackgroundExecution([_socketId]); // Mark as inactive immediately to prevent race conditions
} catch (_) {} _bgActive = false;
_heartbeat?.cancel(); _heartbeat?.cancel();
_heartbeat = null; _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 @override
@@ -503,6 +639,9 @@ class _SocketPersistenceObserver extends WidgetsBindingObserver {
case AppLifecycleState.resumed: case AppLifecycleState.resumed:
_isBackgrounded = false; _isBackgrounded = false;
_stopBackground(); _stopBackground();
// Reconcile background state on resume to detect orphaned services
// or stale Flutter state from native service crashes
_reconcileOnResume();
break; break;
case AppLifecycleState.detached: case AppLifecycleState.detached:
case AppLifecycleState.hidden: 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<bool> type
});
}
// Called when active conversation changes; only acts during background // Called when active conversation changes; only acts during background
void onActiveConversationChanged() { void onActiveConversationChanged() {
if (!_isBackgrounded) return; if (!_isBackgrounded) return;

View File

@@ -3,10 +3,45 @@ import 'dart:io';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import '../utils/debug_logger.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) /// This service keeps the app alive when streaming content in the background,
/// On Android: Uses foreground service notifications /// 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 { class BackgroundStreamingHandler {
static const MethodChannel _channel = MethodChannel( static const MethodChannel _channel = MethodChannel(
'conduit/background_streaming', 'conduit/background_streaming',
@@ -25,7 +60,41 @@ class BackgroundStreamingHandler {
} }
final Set<String> _activeStreamIds = <String>{}; final Set<String> _activeStreamIds = <String>{};
final Map<String, StreamState> _streamStates = <String, StreamState>{}; final Set<String> _microphoneStreamIds = <String>{};
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<void> initialize({
void Function(String error, String errorType, List<String> streamIds)?
serviceFailedCallback,
void Function(int remainingMinutes)? timeLimitApproachingCallback,
void Function()? microphonePermissionFallbackCallback,
void Function(List<String> streamIds)? streamsSuspendingCallback,
void Function()? backgroundTaskExpiringCallback,
void Function(List<String> 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). /// Returns count of actual content streams (excludes socket keepalive).
int get _userVisibleStreamCount => int get _userVisibleStreamCount =>
@@ -69,9 +138,6 @@ class BackgroundStreamingHandler {
data: {'count': streamIds.length, 'reason': reason}, data: {'count': streamIds.length, 'reason': reason},
); );
onStreamsSuspending?.call(streamIds); onStreamsSuspending?.call(streamIds);
// Save stream states for recovery
await saveStreamStatesForRecovery(streamIds, reason);
break; break;
case 'backgroundTaskExpiring': case 'backgroundTaskExpiring':
@@ -120,7 +186,6 @@ class BackgroundStreamingHandler {
// Clean up failed streams // Clean up failed streams
for (final streamId in streamIds) { for (final streamId in streamIds) {
_activeStreamIds.remove(streamId); _activeStreamIds.remove(streamId);
_streamStates.remove(streamId);
} }
break; break;
@@ -139,10 +204,7 @@ class BackgroundStreamingHandler {
break; break;
case 'microphonePermissionFallback': case 'microphonePermissionFallback':
DebugLogger.stream( DebugLogger.stream('mic-permission-fallback', scope: 'background');
'mic-permission-fallback',
scope: 'background',
);
onMicrophonePermissionFallback?.call(); onMicrophonePermissionFallback?.call();
break; break;
@@ -157,18 +219,24 @@ class BackgroundStreamingHandler {
}) async { }) async {
if (!Platform.isIOS && !Platform.isAndroid) return; if (!Platform.isIOS && !Platform.isAndroid) return;
_activeStreamIds.addAll(streamIds);
try { try {
await _channel.invokeMethod('startBackgroundExecution', { await _channel.invokeMethod('startBackgroundExecution', {
'streamIds': streamIds, 'streamIds': streamIds,
'requiresMicrophone': requiresMicrophone, '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( DebugLogger.stream(
'start', 'start',
scope: 'background', scope: 'background',
data: {'count': streamIds.length}, data: {'count': streamIds.length, 'mic': requiresMicrophone},
); );
} catch (e) { } catch (e) {
DebugLogger.error( DebugLogger.error(
@@ -177,6 +245,8 @@ class BackgroundStreamingHandler {
error: e, error: e,
data: {'count': streamIds.length}, data: {'count': streamIds.length},
); );
// Re-throw so callers know the background execution failed
rethrow;
} }
} }
@@ -184,20 +254,27 @@ class BackgroundStreamingHandler {
Future<void> stopBackgroundExecution(List<String> streamIds) async { Future<void> stopBackgroundExecution(List<String> streamIds) async {
if (!Platform.isIOS && !Platform.isAndroid) return; if (!Platform.isIOS && !Platform.isAndroid) return;
_activeStreamIds.removeAll(streamIds);
streamIds.forEach(_streamStates.remove);
try { try {
await _channel.invokeMethod('stopBackgroundExecution', { await _channel.invokeMethod('stopBackgroundExecution', {
'streamIds': streamIds, '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( DebugLogger.stream(
'stop', 'stop',
scope: 'background', scope: 'background',
data: {'count': streamIds.length}, data: {'count': streamIds.length},
); );
} catch (e) { } 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( DebugLogger.error(
'stop-failed', 'stop-failed',
scope: 'background', 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 /// Keep alive the background task
/// ///
/// On iOS: Refreshes background task to prevent early termination /// On iOS: Refreshes background task to prevent early termination
/// On Android: Refreshes wake lock to keep service running /// On Android: Refreshes wake lock to keep service running
Future<void> keepAlive() async { ///
if (!Platform.isIOS && !Platform.isAndroid) return; /// Returns true if keep-alive succeeded, false otherwise.
Future<bool> keepAlive() async {
if (!Platform.isIOS && !Platform.isAndroid) return true;
// Skip keep-alive if no active streams - this ensures Android's count // Skip keep-alive if no active streams - this ensures Android's count
// stays synchronized with Flutter's actual state // stays synchronized with Flutter's actual state
if (_activeStreamIds.isEmpty) return; if (_activeStreamIds.isEmpty) return true;
try { try {
await _channel.invokeMethod('keepAlive', { await _channel.invokeMethod('keepAlive', {
@@ -277,8 +304,32 @@ class BackgroundStreamingHandler {
'streamCount': _userVisibleStreamCount, 'streamCount': _userVisibleStreamCount,
}); });
DebugLogger.stream('keepalive-success', scope: 'background'); DebugLogger.stream('keepalive-success', scope: 'background');
return true;
} catch (e) { } catch (e) {
DebugLogger.error('keepalive-failed', scope: 'background', error: 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<bool> checkBackgroundRefreshStatus() async {
if (!Platform.isIOS) return true;
try {
final bool? status = await _channel.invokeMethod<bool>(
'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<List<StreamState>> recoverStreamStates() async {
if (!Platform.isIOS && !Platform.isAndroid) return [];
try {
final List<dynamic>? states = await _channel.invokeMethod(
'recoverStreamStates',
);
if (states == null) return [];
final recovered = <StreamState>[];
for (final stateData in states) {
// Platform channels return Map<Object?, Object?>, need to convert
final map = Map<String, dynamic>.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<void> saveStreamStatesForRecovery(
List<String> 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 /// Check if any streams are currently active
bool get hasActiveStreams => _activeStreamIds.isNotEmpty; bool get hasActiveStreams => _activeStreamIds.isNotEmpty;
/// Get list of active stream IDs /// Get list of active stream IDs
List<String> get activeStreamIds => _activeStreamIds.toList(); List<String> get activeStreamIds => _activeStreamIds.toList();
/// 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<void> setExternalAudioSessionOwner(bool isExternal) async {
if (!Platform.isIOS) return;
try {
await _channel.invokeMethod('setExternalAudioSessionOwner', {
'isExternal': isExternal,
});
DebugLogger.stream(
isExternal
? 'external-audio-owner-set'
: 'external-audio-owner-cleared',
scope: 'background',
);
} catch (e) {
DebugLogger.error(
'set-external-audio-owner-failed',
scope: 'background',
error: e,
);
}
}
/// Clear all stream data (usually on app termination) /// Clear all stream data (usually on app termination)
void clearAll() { void clearAll() {
_activeStreamIds.clear(); _activeStreamIds.clear();
_streamStates.clear(); _microphoneStreamIds.clear();
}
}
/// 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<String, dynamic> toMap() { /// Reconcile Flutter state with native platform state.
return { ///
'streamId': streamId, /// This should be called on app resume to detect and fix state drift
'conversationId': conversationId, /// caused by native service crashes or other edge cases. Returns true
'messageId': messageId, /// if reconciliation was needed and performed.
'sessionId': sessionId, Future<bool> reconcileState() async {
'lastChunkSequence': lastChunkSequence, if (!Platform.isIOS && !Platform.isAndroid) return false;
'lastContent': lastContent,
'timestamp': timestamp.millisecondsSinceEpoch,
};
}
static StreamState? fromMap(Map<String, dynamic> map) {
try { try {
return StreamState( final int? nativeCount = await _channel.invokeMethod<int>(
streamId: map['streamId'] as String, 'getActiveStreamCount',
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,
),
); );
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) { } catch (e) {
DebugLogger.error('parse-failed', scope: 'background', error: e); DebugLogger.error('reconcile-failed', scope: 'background', error: e);
return null; return false;
} }
} }
/// Check if this state is stale (older than threshold)
bool isStale({Duration threshold = const Duration(minutes: 5)}) {
return DateTime.now().difference(timestamp) > threshold;
}
@override
String toString() {
return 'StreamState(streamId: $streamId, conversationId: $conversationId, '
'messageId: $messageId, sequence: $lastChunkSequence, '
'contentLength: ${lastContent.length}, timestamp: $timestamp)';
}
} }

View File

@@ -1,5 +1,6 @@
import 'dart:async'; import 'dart:async';
import 'dart:convert'; import 'dart:convert';
import 'dart:io';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
@@ -7,6 +8,7 @@ import '../../core/models/chat_message.dart';
import '../../core/models/socket_event.dart'; import '../../core/models/socket_event.dart';
import '../../core/services/socket_service.dart'; import '../../core/services/socket_service.dart';
import '../../core/utils/tool_calls_parser.dart'; import '../../core/utils/tool_calls_parser.dart';
import 'background_streaming_handler.dart';
import 'navigation_service.dart'; import 'navigation_service.dart';
import 'conversation_delta_listener.dart'; import 'conversation_delta_listener.dart';
import '../../shared/widgets/themed_dialogs.dart'; import '../../shared/widgets/themed_dialogs.dart';
@@ -219,11 +221,41 @@ ActiveSocketStream attachUnifiedChunkedStreaming({
// Track if streaming has been finished to avoid duplicate cleanup // Track if streaming has been finished to avoid duplicate cleanup
bool hasFinished = false; 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() { void wrappedFinishStreaming() {
if (hasFinished) return; if (hasFinished) return;
hasFinished = true; hasFinished = true;
api.clearStreamCancelToken(assistantMessageId); 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(); finishStreaming();
} }

View File

@@ -1,6 +1,7 @@
import 'dart:async'; import 'dart:async';
import 'dart:collection'; import 'dart:collection';
import 'dart:developer' as developer; import 'dart:developer' as developer;
import 'dart:io';
import 'package:audioplayers/audioplayers.dart'; import 'package:audioplayers/audioplayers.dart';
import 'package:flutter_callkit_incoming/entities/call_event.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 // Initialize voice input first so we know which STT mode will be used
await _voiceInput.initialize(); await _voiceInput.initialize();
// Only activate VoiceBackgroundAudioManager for server STT // Determine if we need microphone foreground service type.
// For local STT, speech_to_text handles its own iOS audio session // 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 = final useServerMic =
(_voiceInput.prefersServerOnly && _voiceInput.hasServerStt) || (_voiceInput.prefersServerOnly && _voiceInput.hasServerStt) ||
(!_voiceInput.hasLocalStt && _voiceInput.hasServerStt); (!_voiceInput.hasLocalStt && _voiceInput.hasServerStt);
final requiresMicrophone = Platform.isAndroid || useServerMic;
await BackgroundStreamingHandler.instance.startBackgroundExecution(const [ await BackgroundStreamingHandler.instance.startBackgroundExecution(const [
_voiceCallStreamId, _voiceCallStreamId,
], requiresMicrophone: useServerMic); ], requiresMicrophone: requiresMicrophone);
// Set up periodic keep-alive to refresh wake lock (every 5 minutes) // Set up periodic keep-alive to refresh wake lock (every 5 minutes)
_keepAliveTimer?.cancel(); _keepAliveTimer?.cancel();
_keepAliveTimer = Timer.periodic( _keepAliveTimer = Timer.periodic(
const Duration(minutes: 5), 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 // Set up socket event listener for assistant responses