Merge pull request #299 from cogwheel0/fix-background-streaming-sync

fix-background-streaming-sync
This commit is contained in:
cogwheel
2025-12-20 22:21:36 +05:30
committed by GitHub
6 changed files with 851 additions and 441 deletions

View File

@@ -3,14 +3,17 @@ package app.cogwheel.conduit
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
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
@@ -20,12 +23,26 @@ 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 val activeStreams = mutableSetOf<String>()
private var activeStreamCount = 0
private var isForeground = false
private var currentForegroundType: Int = 0
@@ -38,6 +55,10 @@ class BackgroundStreamingService : Service() {
const val ACTION_STOP = "STOP_STREAMING"
const val EXTRA_REQUIRES_MICROPHONE = "requiresMicrophone"
const val EXTRA_STREAM_COUNT = "streamCount"
const val ACTION_TIME_LIMIT_APPROACHING = "app.cogwheel.conduit.TIME_LIMIT_APPROACHING"
const val ACTION_MIC_PERMISSION_FALLBACK = "app.cogwheel.conduit.MIC_PERMISSION_FALLBACK"
const val EXTRA_REMAINING_MINUTES = "remainingMinutes"
}
override fun onCreate() {
@@ -74,7 +95,7 @@ class BackgroundStreamingService : Service() {
ensureNotificationChannel()
val fallbackNotification = NotificationCompat.Builder(this, CHANNEL_ID)
.setContentTitle("Conduit")
.setSmallIcon(android.R.drawable.ic_dialog_info)
.setSmallIcon(R.mipmap.ic_launcher)
.setSilent(true)
.setOngoing(true) // Prevent user from dismissing foreground service notification
.build()
@@ -213,6 +234,8 @@ class BackgroundStreamingService : Service() {
ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC
}
println("BackgroundStreamingService: Microphone permission missing; falling back to data sync type")
// Notify handler about the permission fallback
sendBroadcast(Intent(ACTION_MIC_PERMISSION_FALLBACK))
}
return ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC
@@ -228,11 +251,23 @@ class BackgroundStreamingService : Service() {
private fun createMinimalNotification(): Notification {
ensureNotificationChannel()
// Create PendingIntent to open app when notification is tapped
val launchIntent = packageManager.getLaunchIntentForPackage(packageName)
val pendingIntent = launchIntent?.let {
PendingIntent.getActivity(
this,
0,
it,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
}
// Create a minimal, silent notification (required for foreground service)
return NotificationCompat.Builder(this, CHANNEL_ID)
.setContentTitle("Conduit")
.setContentText("Background service active")
.setSmallIcon(android.R.drawable.ic_dialog_info)
.setSmallIcon(R.mipmap.ic_launcher)
.setContentIntent(pendingIntent)
.setPriority(NotificationCompat.PRIORITY_MIN)
.setCategory(NotificationCompat.CATEGORY_SERVICE)
.setVisibility(NotificationCompat.VISIBILITY_SECRET)
@@ -265,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
@@ -285,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) {
@@ -303,42 +357,49 @@ 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
}
private fun keepAlive() {
if (activeStreamCount <= 0) {
stopStreaming()
return
}
// Check if we're approaching Android 14's 6-hour dataSync limit
// Check if we've hit Android 14's dataSync time limit
// We stop at 5 hours to provide a 1-hour buffer before Android's 6-hour hard limit
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE && isForeground) {
val uptime = System.currentTimeMillis() - foregroundStartTime
val fiveHours = 5 * 60 * 60 * 1000L // 5 hours in milliseconds
val fiveHours = 5 * 60 * 60 * 1000L
if (uptime > fiveHours) {
println("BackgroundStreamingService: Approaching time limit (${uptime / 3600000}h), stopping service")
println("BackgroundStreamingService: Time limit reached (${uptime / 3600000}h), stopping service")
// Notify Flutter before stopping
sendBroadcast(Intent(ACTION_TIME_LIMIT_APPROACHING).apply {
putExtra(EXTRA_REMAINING_MINUTES, 0)
})
stopStreaming()
return
}
}
// Refresh wake lock to maintain CPU availability for streaming.
// Wake lock has 6-minute timeout, keepAlive is called every 5 minutes,
// ensuring continuous coverage with 1-minute overlap buffer.
// Note: Foreground services prevent process termination but NOT CPU sleep.
releaseWakeLock()
acquireWakeLock()
println("BackgroundStreamingService: Keep alive - wake lock refreshed, ${activeStreamCount} active streams")
// activeStreamCount reflects user-visible streams (excludes socket-keepalive)
if (activeStreamCount > 0) {
// Refresh wake lock to maintain CPU availability for actual streaming.
// 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()
println("BackgroundStreamingService: Keep alive - wake lock refreshed, ${activeStreamCount} active streams")
} else {
// No active streams - just socket keepalive running.
// Foreground service keeps app alive; no wakelock needed.
releaseWakeLock()
println("BackgroundStreamingService: Keep alive (background task, no wakelock)")
}
}
private fun stopStreaming() {
println("BackgroundStreamingService: Stopping service...")
activeStreams.clear()
activeStreamCount = 0
releaseWakeLock()
@@ -381,7 +442,6 @@ class BackgroundStreamingService : Service() {
}
}
releaseWakeLock()
activeStreams.clear()
activeStreamCount = 0
isForeground = false
foregroundStartTime = 0
@@ -395,59 +455,93 @@ 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<String>()
private val streamsRequiringMic = mutableSetOf<String>()
private var backgroundJob: Job? = null
private val scope = CoroutineScope(Dispatchers.Main + SupervisorJob())
private var serviceFailureReceiver: android.content.BroadcastReceiver? = null
private var broadcastReceiver: android.content.BroadcastReceiver? = null
private var receiverRegistered = false
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()
setupServiceFailureReceiver()
setupBroadcastReceiver()
}
private fun setupServiceFailureReceiver() {
serviceFailureReceiver = object : android.content.BroadcastReceiver() {
private fun hasNotificationPermission(): Boolean {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) return true
return ContextCompat.checkSelfPermission(
context,
Manifest.permission.POST_NOTIFICATIONS
) == PackageManager.PERMISSION_GRANTED
}
private fun setupBroadcastReceiver() {
if (receiverRegistered) return
broadcastReceiver = object : android.content.BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
if (intent?.action == "app.cogwheel.conduit.FOREGROUND_SERVICE_FAILED") {
val error = intent.getStringExtra("error") ?: "Unknown error"
val errorType = intent.getStringExtra("errorType") ?: "Exception"
when (intent?.action) {
"app.cogwheel.conduit.FOREGROUND_SERVICE_FAILED" -> {
val error = intent.getStringExtra("error") ?: "Unknown error"
val errorType = intent.getStringExtra("errorType") ?: "Exception"
println("BackgroundStreamingHandler: Service failure received: $errorType - $error")
println("BackgroundStreamingHandler: Service failure received: $errorType - $error")
// Notify Flutter about the service failure
channel.invokeMethod("serviceFailed", mapOf(
"error" to error,
"errorType" to errorType,
"streamIds" to activeStreams.toList()
))
// Notify Flutter about the service failure
channel.invokeMethod("serviceFailed", mapOf(
"error" to error,
"errorType" to errorType,
"streamIds" to activeStreams.toList()
))
// Clear active streams since service failed
activeStreams.clear()
streamsRequiringMic.clear()
// Clear active streams since service failed
activeStreams.clear()
streamsRequiringMic.clear()
}
BackgroundStreamingService.ACTION_TIME_LIMIT_APPROACHING -> {
val remainingMinutes = intent.getIntExtra(
BackgroundStreamingService.EXTRA_REMAINING_MINUTES, -1
)
println("BackgroundStreamingHandler: Time limit approaching - $remainingMinutes minutes remaining")
channel.invokeMethod("timeLimitApproaching", mapOf(
"remainingMinutes" to remainingMinutes
))
}
BackgroundStreamingService.ACTION_MIC_PERMISSION_FALLBACK -> {
println("BackgroundStreamingHandler: Microphone permission fallback triggered")
channel.invokeMethod("microphonePermissionFallback", null)
}
}
}
}
val filter = android.content.IntentFilter("app.cogwheel.conduit.FOREGROUND_SERVICE_FAILED")
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
context.registerReceiver(serviceFailureReceiver, filter, Context.RECEIVER_NOT_EXPORTED)
} else {
context.registerReceiver(serviceFailureReceiver, filter)
val filter = android.content.IntentFilter().apply {
addAction("app.cogwheel.conduit.FOREGROUND_SERVICE_FAILED")
addAction(BackgroundStreamingService.ACTION_TIME_LIMIT_APPROACHING)
addAction(BackgroundStreamingService.ACTION_MIC_PERMISSION_FALLBACK)
}
// 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
}
override fun onMethodCall(call: MethodCall, result: Result) {
@@ -474,23 +568,25 @@ class BackgroundStreamingHandler(private val activity: MainActivity) : MethodCal
}
"keepAlive" -> {
keepAlive()
val streamCount = call.argument<Int>("streamCount")
keepAlive(streamCount)
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)
}
"checkNotificationPermission" -> {
result.success(hasNotificationPermission())
}
"recoverStreamStates" -> {
result.success(recoverStreamStates())
"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 -> {
@@ -562,7 +658,10 @@ class BackgroundStreamingHandler(private val activity: MainActivity) : MethodCal
backgroundJob?.cancel()
backgroundJob = scope.launch {
while (activeStreams.isNotEmpty()) {
delay(30000) // Check every 30 seconds
// Check every 5 minutes - matches Flutter keepAlive interval.
// This is a safety mechanism to clean up if Flutter fails to
// call stopBackgroundExecution (e.g., crash recovery).
delay(5 * 60 * 1000L)
// Notify Dart side to check stream health
channel.invokeMethod("checkStreams", null, object : MethodChannel.Result {
@@ -594,15 +693,24 @@ class BackgroundStreamingHandler(private val activity: MainActivity) : MethodCal
backgroundJob = null
}
private fun keepAlive() {
if (activeStreams.isEmpty()) return
private fun keepAlive(userVisibleStreamCount: Int? = null) {
// Check local activeStreams to decide if service should run
// (includes socket-keepalive and other background tasks)
if (activeStreams.isEmpty()) {
stopForegroundService()
return
}
// Use Flutter's user-visible stream count for logging (excludes socket-keepalive)
// Fall back to local count if not provided
val streamCount = userVisibleStreamCount ?: activeStreams.size
try {
val serviceIntent = Intent(context, BackgroundStreamingService::class.java)
serviceIntent.action = "KEEP_ALIVE"
serviceIntent.putExtra(
BackgroundStreamingService.EXTRA_STREAM_COUNT,
activeStreams.size,
streamCount,
)
serviceIntent.putExtra(
BackgroundStreamingService.EXTRA_REQUIRES_MICROPHONE,
@@ -636,85 +744,22 @@ 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() {
scope.cancel()
stopBackgroundMonitoring()
stopForegroundService()
// Unregister broadcast receiver
try {
serviceFailureReceiver?.let {
context.unregisterReceiver(it)
if (receiverRegistered) {
try {
broadcastReceiver?.let {
context.unregisterReceiver(it)
}
} catch (e: Exception) {
println("BackgroundStreamingHandler: Error unregistering receiver: ${e.message}")
}
} catch (e: Exception) {
println("BackgroundStreamingHandler: Error unregistering receiver: ${e.message}")
broadcastReceiver = null
receiverRegistered = false
}
serviceFailureReceiver = null
}
}

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,8 +85,18 @@ 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 {
try session.setActive(false, options: .notifyOthersOnDeactivation)
@@ -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":
if let args = call.arguments as? [String: Any],
let states = args["states"] as? [[String: Any]] {
saveStreamStates(states)
result(nil)
} else {
result(FlutterError(code: "INVALID_ARGS", message: "Invalid arguments", details: nil))
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 "recoverStreamStates":
result(recoverStreamStates())
case "setExternalAudioSessionOwner":
// 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:
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)

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.
///
/// 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<void>.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.
@@ -451,7 +555,7 @@ class _SocketPersistenceObserver extends WidgetsBindingObserver {
final Ref _ref;
_SocketPersistenceObserver(this._ref);
static const String _socketId = 'socket-keepalive';
static const String _socketId = BackgroundStreamingHandler.socketKeepaliveId;
Timer? _heartbeat;
bool _bgActive = false;
bool _isBackgrounded = false;
@@ -467,27 +571,61 @@ class _SocketPersistenceObserver extends WidgetsBindingObserver {
void _startBackground() {
if (_bgActive) return;
if (!_shouldKeepAlive()) return;
try {
BackgroundStreamingHandler.instance.startBackgroundExecution([_socketId]);
// Periodic keep-alive (primarily useful on iOS)
_heartbeat?.cancel();
_heartbeat = Timer.periodic(const Duration(seconds: 30), (_) 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
@@ -501,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:
@@ -510,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
void onActiveConversationChanged() {
if (!_isBackgrounded) return;

View File

@@ -3,15 +3,54 @@ 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',
);
/// Stream ID used for socket keepalive - not counted as an "active stream"
/// since it's a background task, not user-visible streaming.
static const String socketKeepaliveId = 'socket-keepalive';
static BackgroundStreamingHandler? _instance;
static BackgroundStreamingHandler get instance =>
_instance ??= BackgroundStreamingHandler._();
@@ -21,7 +60,45 @@ class BackgroundStreamingHandler {
}
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).
int get _userVisibleStreamCount =>
_activeStreamIds.where((id) => id != socketKeepaliveId).length;
// Callbacks for platform-specific events
void Function(List<String> streamIds)? onStreamsSuspending;
@@ -33,6 +110,15 @@ class BackgroundStreamingHandler {
void Function(String error, String errorType, List<String> streamIds)?
onServiceFailed;
/// Called when Android 14's foreground service time limit is reached.
/// The service stops after 5 hours (buffer before Android's 6-hour limit).
/// [remainingMinutes] will be 0 when this is called.
void Function(int remainingMinutes)? onBackgroundTimeLimitApproaching;
/// Called when microphone permission was requested but not granted,
/// causing fallback to dataSync-only foreground service type.
void Function()? onMicrophonePermissionFallback;
void _setupMethodCallHandler() {
_channel.setMethodCallHandler((call) async {
switch (call.method) {
@@ -52,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':
@@ -103,9 +186,28 @@ class BackgroundStreamingHandler {
// Clean up failed streams
for (final streamId in streamIds) {
_activeStreamIds.remove(streamId);
_streamStates.remove(streamId);
}
break;
case 'timeLimitApproaching':
final Map<String, dynamic> args =
call.arguments as Map<String, dynamic>;
final int remainingMinutes = args['remainingMinutes'] as int? ?? -1;
DebugLogger.stream(
'time-limit-approaching',
scope: 'background',
data: {'remainingMinutes': remainingMinutes},
);
onBackgroundTimeLimitApproaching?.call(remainingMinutes);
break;
case 'microphonePermissionFallback':
DebugLogger.stream('mic-permission-fallback', scope: 'background');
onMicrophonePermissionFallback?.call();
break;
}
});
}
@@ -117,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(
@@ -137,6 +245,8 @@ class BackgroundStreamingHandler {
error: e,
data: {'count': streamIds.length},
);
// Re-throw so callers know the background execution failed
rethrow;
}
}
@@ -144,20 +254,27 @@ class BackgroundStreamingHandler {
Future<void> stopBackgroundExecution(List<String> 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',
@@ -167,150 +284,74 @@ 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<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
// stays synchronized with Flutter's actual state
if (_activeStreamIds.isEmpty) return true;
try {
await _channel.invokeMethod('keepAlive');
await _channel.invokeMethod('keepAlive', {
// Pass user-visible stream count (excludes socket-keepalive)
// for accurate logging, but service still runs for any background task
'streamCount': _userVisibleStreamCount,
});
DebugLogger.stream('keepalive-success', scope: 'background');
return true;
} catch (e) {
DebugLogger.error('keepalive-failed', scope: 'background', error: e);
return false;
}
}
/// Recover stream states from previous app session
Future<List<StreamState>> recoverStreamStates() async {
if (!Platform.isIOS && !Platform.isAndroid) return [];
/// 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 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},
final bool? status = await _channel.invokeMethod<bool>(
'checkBackgroundRefreshStatus',
);
return status ?? true;
} catch (e) {
DebugLogger.error(
'save-states-failed',
'check-background-refresh-failed',
scope: 'background',
error: e,
data: {'count': streamIds.length, 'reason': reason},
);
return true; // Assume available on error to not block functionality
}
}
/// Check if notification permission is granted (Android 13+ only).
///
/// Returns true on iOS, Android < 13, or if permission is granted.
/// Returns false if Android 13+ and permission is not granted.
Future<bool> checkNotificationPermission() async {
if (!Platform.isAndroid) return true;
try {
final bool? hasPermission = await _channel.invokeMethod<bool>(
'checkNotificationPermission',
);
return hasPermission ?? true;
} catch (e) {
DebugLogger.error(
'check-notification-permission-failed',
scope: 'background',
error: e,
);
return true; // Assume granted on error to not block functionality
}
}
@@ -320,93 +361,91 @@ class BackgroundStreamingHandler {
/// Get list of active stream IDs
List<String> 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<void> 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<String, dynamic> toMap() {
return {
'streamId': streamId,
'conversationId': conversationId,
'messageId': messageId,
'sessionId': sessionId,
'lastChunkSequence': lastChunkSequence,
'lastContent': lastContent,
'timestamp': timestamp.millisecondsSinceEpoch,
};
}
static StreamState? fromMap(Map<String, dynamic> 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<bool> reconcileState() async {
if (!Platform.isIOS && !Platform.isAndroid) return false;
try {
final int? nativeCount = await _channel.invokeMethod<int>(
'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;
}
}
}

View File

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

View File

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