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

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