feat(android): Improve background service notification and time limit handling
feat: Optimize background streaming and keepalive mechanism fix(background-streaming): Synchronize stream count between Flutter and Android
This commit is contained in:
@@ -3,12 +3,14 @@ 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.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.IBinder
|
import android.os.IBinder
|
||||||
import android.os.PowerManager
|
import android.os.PowerManager
|
||||||
@@ -25,7 +27,6 @@ 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 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 +39,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 +79,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 +218,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 +235,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)
|
||||||
@@ -310,35 +329,42 @@ class BackgroundStreamingService : Service() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
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.
|
||||||
// Note: Foreground services prevent process termination but NOT CPU sleep.
|
// Wake lock has 6-minute timeout, keepAlive is called every 5 minutes,
|
||||||
releaseWakeLock()
|
// ensuring continuous coverage with 1-minute overlap buffer.
|
||||||
acquireWakeLock()
|
// Note: Foreground services prevent process termination but NOT CPU sleep.
|
||||||
println("BackgroundStreamingService: Keep alive - wake lock refreshed, ${activeStreamCount} active streams")
|
releaseWakeLock()
|
||||||
|
acquireWakeLock()
|
||||||
|
println("BackgroundStreamingService: Keep alive - wake lock refreshed, ${activeStreamCount} active streams")
|
||||||
|
} else {
|
||||||
|
// No active streams - just socket keepalive running.
|
||||||
|
// Foreground service keeps app alive; no wakelock needed.
|
||||||
|
releaseWakeLock()
|
||||||
|
println("BackgroundStreamingService: Keep alive (background task, no wakelock)")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun stopStreaming() {
|
private fun stopStreaming() {
|
||||||
println("BackgroundStreamingService: Stopping service...")
|
println("BackgroundStreamingService: Stopping service...")
|
||||||
activeStreams.clear()
|
|
||||||
activeStreamCount = 0
|
activeStreamCount = 0
|
||||||
releaseWakeLock()
|
releaseWakeLock()
|
||||||
|
|
||||||
@@ -381,7 +407,6 @@ class BackgroundStreamingService : Service() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
releaseWakeLock()
|
releaseWakeLock()
|
||||||
activeStreams.clear()
|
|
||||||
activeStreamCount = 0
|
activeStreamCount = 0
|
||||||
isForeground = false
|
isForeground = false
|
||||||
foregroundStartTime = 0
|
foregroundStartTime = 0
|
||||||
@@ -401,7 +426,8 @@ class BackgroundStreamingHandler(private val activity: MainActivity) : MethodCal
|
|||||||
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"
|
||||||
@@ -416,38 +442,72 @@ class BackgroundStreamingHandler(private val activity: MainActivity) : MethodCal
|
|||||||
sharedPrefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
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) {
|
||||||
val error = intent.getStringExtra("error") ?: "Unknown error"
|
"app.cogwheel.conduit.FOREGROUND_SERVICE_FAILED" -> {
|
||||||
val errorType = intent.getStringExtra("errorType") ?: "Exception"
|
val error = intent.getStringExtra("error") ?: "Unknown error"
|
||||||
|
val errorType = intent.getStringExtra("errorType") ?: "Exception"
|
||||||
|
|
||||||
println("BackgroundStreamingHandler: Service failure received: $errorType - $error")
|
println("BackgroundStreamingHandler: Service failure received: $errorType - $error")
|
||||||
|
|
||||||
// Notify Flutter about the service failure
|
// Notify Flutter about the service failure
|
||||||
channel.invokeMethod("serviceFailed", mapOf(
|
channel.invokeMethod("serviceFailed", mapOf(
|
||||||
"error" to error,
|
"error" to error,
|
||||||
"errorType" to errorType,
|
"errorType" to errorType,
|
||||||
"streamIds" to activeStreams.toList()
|
"streamIds" to activeStreams.toList()
|
||||||
))
|
))
|
||||||
|
|
||||||
// Clear active streams since service failed
|
// Clear active streams since service failed
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||||
|
context.registerReceiver(broadcastReceiver, filter, Context.RECEIVER_NOT_EXPORTED)
|
||||||
|
} else {
|
||||||
|
context.registerReceiver(broadcastReceiver, filter)
|
||||||
|
}
|
||||||
|
receiverRegistered = true
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onMethodCall(call: MethodCall, result: Result) {
|
override fun onMethodCall(call: MethodCall, result: Result) {
|
||||||
@@ -474,7 +534,8 @@ class BackgroundStreamingHandler(private val activity: MainActivity) : MethodCal
|
|||||||
}
|
}
|
||||||
|
|
||||||
"keepAlive" -> {
|
"keepAlive" -> {
|
||||||
keepAlive()
|
val streamCount = call.argument<Int>("streamCount")
|
||||||
|
keepAlive(streamCount)
|
||||||
result.success(null)
|
result.success(null)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -493,6 +554,10 @@ class BackgroundStreamingHandler(private val activity: MainActivity) : MethodCal
|
|||||||
result.success(recoverStreamStates())
|
result.success(recoverStreamStates())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
"checkNotificationPermission" -> {
|
||||||
|
result.success(hasNotificationPermission())
|
||||||
|
}
|
||||||
|
|
||||||
else -> {
|
else -> {
|
||||||
result.notImplemented()
|
result.notImplemented()
|
||||||
}
|
}
|
||||||
@@ -562,7 +627,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 +662,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,
|
||||||
@@ -708,13 +785,16 @@ class BackgroundStreamingHandler(private val activity: MainActivity) : MethodCal
|
|||||||
stopForegroundService()
|
stopForegroundService()
|
||||||
|
|
||||||
// Unregister broadcast receiver
|
// Unregister broadcast receiver
|
||||||
try {
|
if (receiverRegistered) {
|
||||||
serviceFailureReceiver?.let {
|
try {
|
||||||
context.unregisterReceiver(it)
|
broadcastReceiver?.let {
|
||||||
|
context.unregisterReceiver(it)
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
println("BackgroundStreamingHandler: Error unregistering receiver: ${e.message}")
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
broadcastReceiver = null
|
||||||
println("BackgroundStreamingHandler: Error unregistering receiver: ${e.message}")
|
receiverRegistered = false
|
||||||
}
|
}
|
||||||
serviceFailureReceiver = null
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -451,7 +451,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;
|
||||||
@@ -469,9 +469,11 @@ class _SocketPersistenceObserver extends WidgetsBindingObserver {
|
|||||||
if (!_shouldKeepAlive()) return;
|
if (!_shouldKeepAlive()) return;
|
||||||
try {
|
try {
|
||||||
BackgroundStreamingHandler.instance.startBackgroundExecution([_socketId]);
|
BackgroundStreamingHandler.instance.startBackgroundExecution([_socketId]);
|
||||||
// Periodic keep-alive (primarily useful on iOS)
|
// 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?.cancel();
|
||||||
_heartbeat = Timer.periodic(const Duration(seconds: 30), (_) async {
|
_heartbeat = Timer.periodic(const Duration(minutes: 5), (_) async {
|
||||||
try {
|
try {
|
||||||
await BackgroundStreamingHandler.instance.keepAlive();
|
await BackgroundStreamingHandler.instance.keepAlive();
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
|
|||||||
@@ -12,6 +12,10 @@ class BackgroundStreamingHandler {
|
|||||||
'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._();
|
||||||
@@ -23,6 +27,10 @@ class BackgroundStreamingHandler {
|
|||||||
final Set<String> _activeStreamIds = <String>{};
|
final Set<String> _activeStreamIds = <String>{};
|
||||||
final Map<String, StreamState> _streamStates = <String, StreamState>{};
|
final Map<String, StreamState> _streamStates = <String, StreamState>{};
|
||||||
|
|
||||||
|
/// 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;
|
||||||
void Function()? onBackgroundTaskExpiring;
|
void Function()? onBackgroundTaskExpiring;
|
||||||
@@ -33,6 +41,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) {
|
||||||
@@ -106,6 +123,29 @@ class BackgroundStreamingHandler {
|
|||||||
_streamStates.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;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -226,14 +266,44 @@ class BackgroundStreamingHandler {
|
|||||||
Future<void> keepAlive() async {
|
Future<void> keepAlive() async {
|
||||||
if (!Platform.isIOS && !Platform.isAndroid) return;
|
if (!Platform.isIOS && !Platform.isAndroid) return;
|
||||||
|
|
||||||
|
// Skip keep-alive if no active streams - this ensures Android's count
|
||||||
|
// stays synchronized with Flutter's actual state
|
||||||
|
if (_activeStreamIds.isEmpty) return;
|
||||||
|
|
||||||
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');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
DebugLogger.error('keepalive-failed', scope: 'background', error: e);
|
DebugLogger.error('keepalive-failed', scope: 'background', error: e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Recover stream states from previous app session
|
/// Recover stream states from previous app session
|
||||||
Future<List<StreamState>> recoverStreamStates() async {
|
Future<List<StreamState>> recoverStreamStates() async {
|
||||||
if (!Platform.isIOS && !Platform.isAndroid) return [];
|
if (!Platform.isIOS && !Platform.isAndroid) return [];
|
||||||
|
|||||||
Reference in New Issue
Block a user