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:
cogwheel
2025-12-20 18:21:38 +05:30
parent f8d0911b23
commit 671b953f23
3 changed files with 210 additions and 58 deletions

View File

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

View File

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

View File

@@ -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 [];