Merge pull request #299 from cogwheel0/fix-background-streaming-sync
fix-background-streaming-sync
This commit is contained in:
@@ -3,14 +3,17 @@ package app.cogwheel.conduit
|
|||||||
import android.app.Notification
|
import android.app.Notification
|
||||||
import android.app.NotificationChannel
|
import android.app.NotificationChannel
|
||||||
import android.app.NotificationManager
|
import android.app.NotificationManager
|
||||||
|
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.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
|
||||||
@@ -20,12 +23,26 @@ 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 val activeStreams = mutableSetOf<String>()
|
|
||||||
private var activeStreamCount = 0
|
private var activeStreamCount = 0
|
||||||
private var isForeground = false
|
private var isForeground = false
|
||||||
private var currentForegroundType: Int = 0
|
private var currentForegroundType: Int = 0
|
||||||
@@ -38,6 +55,10 @@ class BackgroundStreamingService : Service() {
|
|||||||
const val ACTION_STOP = "STOP_STREAMING"
|
const val ACTION_STOP = "STOP_STREAMING"
|
||||||
const val EXTRA_REQUIRES_MICROPHONE = "requiresMicrophone"
|
const val EXTRA_REQUIRES_MICROPHONE = "requiresMicrophone"
|
||||||
const val EXTRA_STREAM_COUNT = "streamCount"
|
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() {
|
override fun onCreate() {
|
||||||
@@ -74,7 +95,7 @@ class BackgroundStreamingService : Service() {
|
|||||||
ensureNotificationChannel()
|
ensureNotificationChannel()
|
||||||
val fallbackNotification = NotificationCompat.Builder(this, CHANNEL_ID)
|
val fallbackNotification = NotificationCompat.Builder(this, CHANNEL_ID)
|
||||||
.setContentTitle("Conduit")
|
.setContentTitle("Conduit")
|
||||||
.setSmallIcon(android.R.drawable.ic_dialog_info)
|
.setSmallIcon(R.mipmap.ic_launcher)
|
||||||
.setSilent(true)
|
.setSilent(true)
|
||||||
.setOngoing(true) // Prevent user from dismissing foreground service notification
|
.setOngoing(true) // Prevent user from dismissing foreground service notification
|
||||||
.build()
|
.build()
|
||||||
@@ -213,6 +234,8 @@ class BackgroundStreamingService : Service() {
|
|||||||
ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC
|
ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC
|
||||||
}
|
}
|
||||||
println("BackgroundStreamingService: Microphone permission missing; falling back to data sync type")
|
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
|
return ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC
|
||||||
@@ -228,11 +251,23 @@ class BackgroundStreamingService : Service() {
|
|||||||
private fun createMinimalNotification(): Notification {
|
private fun createMinimalNotification(): Notification {
|
||||||
ensureNotificationChannel()
|
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)
|
// Create a minimal, silent notification (required for foreground service)
|
||||||
return NotificationCompat.Builder(this, CHANNEL_ID)
|
return NotificationCompat.Builder(this, CHANNEL_ID)
|
||||||
.setContentTitle("Conduit")
|
.setContentTitle("Conduit")
|
||||||
.setContentText("Background service active")
|
.setContentText("Background service active")
|
||||||
.setSmallIcon(android.R.drawable.ic_dialog_info)
|
.setSmallIcon(R.mipmap.ic_launcher)
|
||||||
|
.setContentIntent(pendingIntent)
|
||||||
.setPriority(NotificationCompat.PRIORITY_MIN)
|
.setPriority(NotificationCompat.PRIORITY_MIN)
|
||||||
.setCategory(NotificationCompat.CATEGORY_SERVICE)
|
.setCategory(NotificationCompat.CATEGORY_SERVICE)
|
||||||
.setVisibility(NotificationCompat.VISIBILITY_SECRET)
|
.setVisibility(NotificationCompat.VISIBILITY_SECRET)
|
||||||
@@ -265,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
|
||||||
@@ -285,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) {
|
||||||
@@ -303,42 +357,49 @@ 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
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun keepAlive() {
|
private fun keepAlive() {
|
||||||
if (activeStreamCount <= 0) {
|
// Check if we've hit Android 14's dataSync time limit
|
||||||
stopStreaming()
|
// We stop at 5 hours to provide a 1-hour buffer before Android's 6-hour hard limit
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if we're approaching Android 14's 6-hour dataSync limit
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE && isForeground) {
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE && isForeground) {
|
||||||
val uptime = System.currentTimeMillis() - foregroundStartTime
|
val uptime = System.currentTimeMillis() - foregroundStartTime
|
||||||
val fiveHours = 5 * 60 * 60 * 1000L // 5 hours in milliseconds
|
val fiveHours = 5 * 60 * 60 * 1000L
|
||||||
|
|
||||||
if (uptime > fiveHours) {
|
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()
|
stopStreaming()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Refresh wake lock to maintain CPU availability for streaming.
|
// activeStreamCount reflects user-visible streams (excludes socket-keepalive)
|
||||||
// Wake lock has 6-minute timeout, keepAlive is called every 5 minutes,
|
if (activeStreamCount > 0) {
|
||||||
// ensuring continuous coverage with 1-minute overlap buffer.
|
// 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.
|
// Note: Foreground services prevent process termination but NOT CPU sleep.
|
||||||
releaseWakeLock()
|
releaseWakeLock()
|
||||||
acquireWakeLock()
|
acquireWakeLock()
|
||||||
println("BackgroundStreamingService: Keep alive - wake lock refreshed, ${activeStreamCount} active streams")
|
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() {
|
private fun stopStreaming() {
|
||||||
println("BackgroundStreamingService: Stopping service...")
|
println("BackgroundStreamingService: Stopping service...")
|
||||||
activeStreams.clear()
|
|
||||||
activeStreamCount = 0
|
activeStreamCount = 0
|
||||||
releaseWakeLock()
|
releaseWakeLock()
|
||||||
|
|
||||||
@@ -381,7 +442,6 @@ class BackgroundStreamingService : Service() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
releaseWakeLock()
|
releaseWakeLock()
|
||||||
activeStreams.clear()
|
|
||||||
activeStreamCount = 0
|
activeStreamCount = 0
|
||||||
isForeground = false
|
isForeground = false
|
||||||
foregroundStartTime = 0
|
foregroundStartTime = 0
|
||||||
@@ -395,34 +455,42 @@ 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>()
|
||||||
private var backgroundJob: Job? = null
|
private var backgroundJob: Job? = null
|
||||||
private val scope = CoroutineScope(Dispatchers.Main + SupervisorJob())
|
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 {
|
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()
|
||||||
setupServiceFailureReceiver()
|
setupBroadcastReceiver()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun setupServiceFailureReceiver() {
|
private fun hasNotificationPermission(): Boolean {
|
||||||
serviceFailureReceiver = object : android.content.BroadcastReceiver() {
|
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?) {
|
override fun onReceive(context: Context?, intent: Intent?) {
|
||||||
if (intent?.action == "app.cogwheel.conduit.FOREGROUND_SERVICE_FAILED") {
|
when (intent?.action) {
|
||||||
|
"app.cogwheel.conduit.FOREGROUND_SERVICE_FAILED" -> {
|
||||||
val error = intent.getStringExtra("error") ?: "Unknown error"
|
val error = intent.getStringExtra("error") ?: "Unknown error"
|
||||||
val errorType = intent.getStringExtra("errorType") ?: "Exception"
|
val errorType = intent.getStringExtra("errorType") ?: "Exception"
|
||||||
|
|
||||||
@@ -439,15 +507,41 @@ class BackgroundStreamingHandler(private val activity: MainActivity) : MethodCal
|
|||||||
activeStreams.clear()
|
activeStreams.clear()
|
||||||
streamsRequiringMic.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")
|
val filter = android.content.IntentFilter().apply {
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
addAction("app.cogwheel.conduit.FOREGROUND_SERVICE_FAILED")
|
||||||
context.registerReceiver(serviceFailureReceiver, filter, Context.RECEIVER_NOT_EXPORTED)
|
addAction(BackgroundStreamingService.ACTION_TIME_LIMIT_APPROACHING)
|
||||||
} else {
|
addAction(BackgroundStreamingService.ACTION_MIC_PERMISSION_FALLBACK)
|
||||||
context.registerReceiver(serviceFailureReceiver, filter)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Use ContextCompat.registerReceiver for unified handling across API levels
|
||||||
|
// RECEIVER_NOT_EXPORTED ensures security on all versions (internal broadcasts only)
|
||||||
|
ContextCompat.registerReceiver(
|
||||||
|
context,
|
||||||
|
broadcastReceiver,
|
||||||
|
filter,
|
||||||
|
ContextCompat.RECEIVER_NOT_EXPORTED
|
||||||
|
)
|
||||||
|
receiverRegistered = true
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onMethodCall(call: MethodCall, result: Result) {
|
override fun onMethodCall(call: MethodCall, result: Result) {
|
||||||
@@ -474,23 +568,25 @@ class BackgroundStreamingHandler(private val activity: MainActivity) : MethodCal
|
|||||||
}
|
}
|
||||||
|
|
||||||
"keepAlive" -> {
|
"keepAlive" -> {
|
||||||
keepAlive()
|
val streamCount = call.argument<Int>("streamCount")
|
||||||
|
keepAlive(streamCount)
|
||||||
result.success(null)
|
result.success(null)
|
||||||
}
|
}
|
||||||
|
|
||||||
"saveStreamStates" -> {
|
"checkNotificationPermission" -> {
|
||||||
val states = call.argument<List<Map<String, Any>>>("states")
|
result.success(hasNotificationPermission())
|
||||||
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" -> {
|
"getActiveStreamCount" -> {
|
||||||
result.success(recoverStreamStates())
|
// 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 -> {
|
||||||
@@ -562,7 +658,10 @@ class BackgroundStreamingHandler(private val activity: MainActivity) : MethodCal
|
|||||||
backgroundJob?.cancel()
|
backgroundJob?.cancel()
|
||||||
backgroundJob = scope.launch {
|
backgroundJob = scope.launch {
|
||||||
while (activeStreams.isNotEmpty()) {
|
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
|
// Notify Dart side to check stream health
|
||||||
channel.invokeMethod("checkStreams", null, object : MethodChannel.Result {
|
channel.invokeMethod("checkStreams", null, object : MethodChannel.Result {
|
||||||
@@ -594,15 +693,24 @@ class BackgroundStreamingHandler(private val activity: MainActivity) : MethodCal
|
|||||||
backgroundJob = null
|
backgroundJob = null
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun keepAlive() {
|
private fun keepAlive(userVisibleStreamCount: Int? = null) {
|
||||||
if (activeStreams.isEmpty()) return
|
// 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 {
|
try {
|
||||||
val serviceIntent = Intent(context, BackgroundStreamingService::class.java)
|
val serviceIntent = Intent(context, BackgroundStreamingService::class.java)
|
||||||
serviceIntent.action = "KEEP_ALIVE"
|
serviceIntent.action = "KEEP_ALIVE"
|
||||||
serviceIntent.putExtra(
|
serviceIntent.putExtra(
|
||||||
BackgroundStreamingService.EXTRA_STREAM_COUNT,
|
BackgroundStreamingService.EXTRA_STREAM_COUNT,
|
||||||
activeStreams.size,
|
streamCount,
|
||||||
)
|
)
|
||||||
serviceIntent.putExtra(
|
serviceIntent.putExtra(
|
||||||
BackgroundStreamingService.EXTRA_REQUIRES_MICROPHONE,
|
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() {
|
fun cleanup() {
|
||||||
scope.cancel()
|
scope.cancel()
|
||||||
stopBackgroundMonitoring()
|
stopBackgroundMonitoring()
|
||||||
stopForegroundService()
|
stopForegroundService()
|
||||||
|
|
||||||
// Unregister broadcast receiver
|
// Unregister broadcast receiver
|
||||||
|
if (receiverRegistered) {
|
||||||
try {
|
try {
|
||||||
serviceFailureReceiver?.let {
|
broadcastReceiver?.let {
|
||||||
context.unregisterReceiver(it)
|
context.unregisterReceiver(it)
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
println("BackgroundStreamingHandler: Error unregistering receiver: ${e.message}")
|
println("BackgroundStreamingHandler: Error unregistering receiver: ${e.message}")
|
||||||
}
|
}
|
||||||
serviceFailureReceiver = null
|
broadcastReceiver = null
|
||||||
|
receiverRegistered = false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -451,7 +555,7 @@ class _SocketPersistenceObserver extends WidgetsBindingObserver {
|
|||||||
final Ref _ref;
|
final Ref _ref;
|
||||||
_SocketPersistenceObserver(this._ref);
|
_SocketPersistenceObserver(this._ref);
|
||||||
|
|
||||||
static const String _socketId = 'socket-keepalive';
|
static const String _socketId = BackgroundStreamingHandler.socketKeepaliveId;
|
||||||
Timer? _heartbeat;
|
Timer? _heartbeat;
|
||||||
bool _bgActive = false;
|
bool _bgActive = false;
|
||||||
bool _isBackgrounded = false;
|
bool _isBackgrounded = false;
|
||||||
@@ -467,27 +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
|
||||||
// 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;
|
_bgActive = true;
|
||||||
} catch (_) {}
|
|
||||||
|
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() {
|
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
|
||||||
@@ -501,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:
|
||||||
@@ -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
|
// Called when active conversation changes; only acts during background
|
||||||
void onActiveConversationChanged() {
|
void onActiveConversationChanged() {
|
||||||
if (!_isBackgrounded) return;
|
if (!_isBackgrounded) return;
|
||||||
|
|||||||
@@ -3,15 +3,54 @@ 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',
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/// 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? _instance;
|
||||||
static BackgroundStreamingHandler get instance =>
|
static BackgroundStreamingHandler get instance =>
|
||||||
_instance ??= BackgroundStreamingHandler._();
|
_instance ??= BackgroundStreamingHandler._();
|
||||||
@@ -21,7 +60,45 @@ 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).
|
||||||
|
int get _userVisibleStreamCount =>
|
||||||
|
_activeStreamIds.where((id) => id != socketKeepaliveId).length;
|
||||||
|
|
||||||
// Callbacks for platform-specific events
|
// Callbacks for platform-specific events
|
||||||
void Function(List<String> streamIds)? onStreamsSuspending;
|
void Function(List<String> streamIds)? onStreamsSuspending;
|
||||||
@@ -33,6 +110,15 @@ class BackgroundStreamingHandler {
|
|||||||
void Function(String error, String errorType, List<String> streamIds)?
|
void Function(String error, String errorType, List<String> streamIds)?
|
||||||
onServiceFailed;
|
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() {
|
void _setupMethodCallHandler() {
|
||||||
_channel.setMethodCallHandler((call) async {
|
_channel.setMethodCallHandler((call) async {
|
||||||
switch (call.method) {
|
switch (call.method) {
|
||||||
@@ -52,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':
|
||||||
@@ -103,9 +186,28 @@ 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;
|
||||||
|
|
||||||
|
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 {
|
}) 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(
|
||||||
@@ -137,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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -144,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',
|
||||||
@@ -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
|
/// 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
|
||||||
|
// stays synchronized with Flutter's actual state
|
||||||
|
if (_activeStreamIds.isEmpty) return true;
|
||||||
|
|
||||||
try {
|
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');
|
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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Recover stream states from previous app session
|
/// Check if background app refresh is enabled (iOS only).
|
||||||
Future<List<StreamState>> recoverStreamStates() async {
|
///
|
||||||
if (!Platform.isIOS && !Platform.isAndroid) return [];
|
/// 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 {
|
try {
|
||||||
final List<dynamic>? states = await _channel.invokeMethod(
|
final bool? status = await _channel.invokeMethod<bool>(
|
||||||
'recoverStreamStates',
|
'checkBackgroundRefreshStatus',
|
||||||
);
|
|
||||||
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},
|
|
||||||
);
|
);
|
||||||
|
return status ?? true;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
DebugLogger.error(
|
DebugLogger.error(
|
||||||
'save-states-failed',
|
'check-background-refresh-failed',
|
||||||
scope: 'background',
|
scope: 'background',
|
||||||
error: e,
|
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
|
/// 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)';
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user