Merge pull request #171 from cogwheel0/android-improve-background-streaming-service

feat(android): Improve background streaming service reliability
This commit is contained in:
cogwheel
2025-11-24 21:36:44 +05:30
committed by GitHub

View File

@@ -13,7 +13,6 @@ import android.os.Build
import android.os.IBinder import android.os.IBinder
import android.os.PowerManager import android.os.PowerManager
import androidx.core.app.NotificationCompat import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import io.flutter.embedding.engine.FlutterEngine import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugin.common.MethodCall import io.flutter.plugin.common.MethodCall
@@ -27,6 +26,7 @@ import org.json.JSONObject
class BackgroundStreamingService : Service() { class BackgroundStreamingService : Service() {
private var wakeLock: PowerManager.WakeLock? = null private var wakeLock: PowerManager.WakeLock? = null
private val activeStreams = mutableSetOf<String>() private val activeStreams = mutableSetOf<String>()
private var activeStreamCount = 0
private var isForeground = false private var isForeground = false
private var currentForegroundType: Int = 0 private var currentForegroundType: Int = 0
private var foregroundStartTime: Long = 0 private var foregroundStartTime: Long = 0
@@ -37,49 +37,82 @@ class BackgroundStreamingService : Service() {
const val ACTION_START = "START_STREAMING" const val ACTION_START = "START_STREAMING"
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"
} }
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
println("BackgroundStreamingService: Service created") println("BackgroundStreamingService: Service created")
// Enter foreground immediately to satisfy Android's 5s requirement even
// if onStartCommand handling is delayed or cancelled.
val initialType = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC
} else {
0
}
if (!isForeground) {
val notification = createMinimalNotification()
startForegroundInternal(notification, initialType)
}
} }
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
when (intent?.action) { val action = intent?.action
ACTION_STOP -> { val incomingStreamCount =
stopStreaming() intent?.getIntExtra(EXTRA_STREAM_COUNT, 0) ?: 0
return START_NOT_STICKY activeStreamCount = incomingStreamCount
}
}
// For KEEP_ALIVE, only refresh the wake lock without restarting foreground
if (intent?.action == "KEEP_ALIVE") {
keepAlive()
return START_STICKY
}
val notification = createMinimalNotification()
val desiredType = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { val desiredType = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
resolveForegroundServiceType(intent) resolveForegroundServiceType(intent)
} else { } else {
0 0
} }
if (!isForeground) { // Always enter foreground as early as possible to avoid the 5s timeout
if (!startForegroundInternal(notification, desiredType)) { // even when stop/keep-alive races deliver a STOP intent first.
val needsTypeUpdate = Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q &&
currentForegroundType != desiredType
if (!isForeground || needsTypeUpdate) {
val notification = createMinimalNotification()
val enteredForeground = if (!isForeground) {
startForegroundInternal(notification, desiredType)
} else {
updateForegroundType(notification, desiredType)
true
}
if (!enteredForeground) {
stopSelf() stopSelf()
return START_NOT_STICKY return START_NOT_STICKY
} }
} else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q &&
currentForegroundType != desiredType // If no streams are active after entering foreground, shut down to
) { // avoid lingering foreground instances that could trigger
updateForegroundType(notification, desiredType) // DidNotStopInTime exceptions.
if (activeStreamCount <= 0) {
stopStreaming()
return START_NOT_STICKY
}
} }
when (intent?.action) { when (action) {
ACTION_STOP -> {
stopStreaming()
return START_NOT_STICKY
}
"KEEP_ALIVE" -> {
keepAlive()
return START_STICKY
}
ACTION_START -> { ACTION_START -> {
acquireWakeLock() if (activeStreamCount > 0) {
println("BackgroundStreamingService: Started foreground service") acquireWakeLock()
println("BackgroundStreamingService: Started foreground service")
} else {
println("BackgroundStreamingService: No active streams; skipping wake lock")
}
} }
} }
@@ -149,6 +182,8 @@ class BackgroundStreamingService : Service() {
} }
private fun createMinimalNotification(): Notification { private fun createMinimalNotification(): Notification {
ensureNotificationChannel()
// 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")
@@ -162,6 +197,29 @@ class BackgroundStreamingService : Service() {
.setSilent(true) .setSilent(true)
.build() .build()
} }
private fun ensureNotificationChannel() {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
val manager =
getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
if (manager.getNotificationChannel(CHANNEL_ID) != null) return
val channel = NotificationChannel(
CHANNEL_ID,
"Background Service",
NotificationManager.IMPORTANCE_MIN,
).apply {
description = "Background service for Conduit"
setShowBadge(false)
enableLights(false)
enableVibration(false)
setSound(null, null)
lockscreenVisibility = Notification.VISIBILITY_SECRET
}
manager.createNotificationChannel(channel)
}
private fun acquireWakeLock() { private fun acquireWakeLock() {
if (wakeLock?.isHeld == true) return if (wakeLock?.isHeld == true) return
@@ -189,6 +247,11 @@ class BackgroundStreamingService : Service() {
} }
private fun keepAlive() { private fun keepAlive() {
if (activeStreamCount <= 0) {
stopStreaming()
return
}
// Check if we're approaching Android 14's 6-hour dataSync limit // 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
@@ -210,6 +273,7 @@ class BackgroundStreamingService : Service() {
private fun stopStreaming() { private fun stopStreaming() {
println("BackgroundStreamingService: Stopping service...") println("BackgroundStreamingService: Stopping service...")
activeStreams.clear() activeStreams.clear()
activeStreamCount = 0
releaseWakeLock() releaseWakeLock()
if (isForeground) { if (isForeground) {
@@ -238,8 +302,21 @@ class BackgroundStreamingService : Service() {
override fun onDestroy() { override fun onDestroy() {
println("BackgroundStreamingService: onDestroy called") println("BackgroundStreamingService: onDestroy called")
if (isForeground) {
try {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
stopForeground(STOP_FOREGROUND_REMOVE)
} else {
@Suppress("DEPRECATION")
stopForeground(true)
}
} catch (e: Exception) {
println("BackgroundStreamingService: Error stopping foreground in onDestroy: ${e.message}")
}
}
releaseWakeLock() releaseWakeLock()
activeStreams.clear() activeStreams.clear()
activeStreamCount = 0
isForeground = false isForeground = false
foregroundStartTime = 0 foregroundStartTime = 0
super.onDestroy() super.onDestroy()
@@ -382,7 +459,10 @@ class BackgroundStreamingHandler(private val activity: MainActivity) : MethodCal
private fun startForegroundService() { private fun startForegroundService() {
try { try {
val serviceIntent = Intent(context, BackgroundStreamingService::class.java) val serviceIntent = Intent(context, BackgroundStreamingService::class.java)
serviceIntent.putExtra("streamCount", activeStreams.size) serviceIntent.putExtra(
BackgroundStreamingService.EXTRA_STREAM_COUNT,
activeStreams.size,
)
serviceIntent.putExtra( serviceIntent.putExtra(
BackgroundStreamingService.EXTRA_REQUIRES_MICROPHONE, BackgroundStreamingService.EXTRA_REQUIRES_MICROPHONE,
streamsRequiringMic.isNotEmpty(), streamsRequiringMic.isNotEmpty(),
@@ -454,7 +534,10 @@ class BackgroundStreamingHandler(private val activity: MainActivity) : MethodCal
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("streamCount", activeStreams.size) serviceIntent.putExtra(
BackgroundStreamingService.EXTRA_STREAM_COUNT,
activeStreams.size,
)
serviceIntent.putExtra( serviceIntent.putExtra(
BackgroundStreamingService.EXTRA_REQUIRES_MICROPHONE, BackgroundStreamingService.EXTRA_REQUIRES_MICROPHONE,
streamsRequiringMic.isNotEmpty(), streamsRequiringMic.isNotEmpty(),