From 671b953f239b4c1b5e106de293f1547ae0127c3c Mon Sep 17 00:00:00 2001 From: cogwheel <172976095+cogwheel0@users.noreply.github.com> Date: Sat, 20 Dec 2025 18:21:38 +0530 Subject: [PATCH] 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 --- .../conduit/BackgroundStreamingHandler.kt | 188 +++++++++++++----- lib/core/providers/app_startup_providers.dart | 8 +- .../background_streaming_handler.dart | 72 ++++++- 3 files changed, 210 insertions(+), 58 deletions(-) diff --git a/android/app/src/main/kotlin/app/cogwheel/conduit/BackgroundStreamingHandler.kt b/android/app/src/main/kotlin/app/cogwheel/conduit/BackgroundStreamingHandler.kt index f8e58fc..a942e08 100644 --- a/android/app/src/main/kotlin/app/cogwheel/conduit/BackgroundStreamingHandler.kt +++ b/android/app/src/main/kotlin/app/cogwheel/conduit/BackgroundStreamingHandler.kt @@ -3,12 +3,14 @@ package app.cogwheel.conduit import android.app.Notification import android.app.NotificationChannel import android.app.NotificationManager +import android.app.PendingIntent import android.app.Service import android.content.Context import android.content.Intent import android.content.SharedPreferences import android.content.pm.PackageManager import android.content.pm.ServiceInfo +import android.Manifest import android.os.Build import android.os.IBinder import android.os.PowerManager @@ -25,7 +27,6 @@ import org.json.JSONObject class BackgroundStreamingService : Service() { private var wakeLock: PowerManager.WakeLock? = null - private val activeStreams = mutableSetOf() private var activeStreamCount = 0 private var isForeground = false private var currentForegroundType: Int = 0 @@ -38,6 +39,10 @@ class BackgroundStreamingService : Service() { const val ACTION_STOP = "STOP_STREAMING" const val EXTRA_REQUIRES_MICROPHONE = "requiresMicrophone" const val EXTRA_STREAM_COUNT = "streamCount" + + const val ACTION_TIME_LIMIT_APPROACHING = "app.cogwheel.conduit.TIME_LIMIT_APPROACHING" + const val ACTION_MIC_PERMISSION_FALLBACK = "app.cogwheel.conduit.MIC_PERMISSION_FALLBACK" + const val EXTRA_REMAINING_MINUTES = "remainingMinutes" } override fun onCreate() { @@ -74,7 +79,7 @@ class BackgroundStreamingService : Service() { ensureNotificationChannel() val fallbackNotification = NotificationCompat.Builder(this, CHANNEL_ID) .setContentTitle("Conduit") - .setSmallIcon(android.R.drawable.ic_dialog_info) + .setSmallIcon(R.mipmap.ic_launcher) .setSilent(true) .setOngoing(true) // Prevent user from dismissing foreground service notification .build() @@ -213,6 +218,8 @@ class BackgroundStreamingService : Service() { ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC } println("BackgroundStreamingService: Microphone permission missing; falling back to data sync type") + // Notify handler about the permission fallback + sendBroadcast(Intent(ACTION_MIC_PERMISSION_FALLBACK)) } return ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC @@ -228,11 +235,23 @@ class BackgroundStreamingService : Service() { private fun createMinimalNotification(): Notification { ensureNotificationChannel() + // Create PendingIntent to open app when notification is tapped + val launchIntent = packageManager.getLaunchIntentForPackage(packageName) + val pendingIntent = launchIntent?.let { + PendingIntent.getActivity( + this, + 0, + it, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) + } + // Create a minimal, silent notification (required for foreground service) return NotificationCompat.Builder(this, CHANNEL_ID) .setContentTitle("Conduit") .setContentText("Background service active") - .setSmallIcon(android.R.drawable.ic_dialog_info) + .setSmallIcon(R.mipmap.ic_launcher) + .setContentIntent(pendingIntent) .setPriority(NotificationCompat.PRIORITY_MIN) .setCategory(NotificationCompat.CATEGORY_SERVICE) .setVisibility(NotificationCompat.VISIBILITY_SECRET) @@ -310,35 +329,42 @@ class BackgroundStreamingService : Service() { } private fun keepAlive() { - if (activeStreamCount <= 0) { - stopStreaming() - return - } - - // Check if we're approaching Android 14's 6-hour dataSync limit + // Check if we've hit Android 14's dataSync time limit + // We stop at 5 hours to provide a 1-hour buffer before Android's 6-hour hard limit if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE && isForeground) { val uptime = System.currentTimeMillis() - foregroundStartTime - val fiveHours = 5 * 60 * 60 * 1000L // 5 hours in milliseconds + val fiveHours = 5 * 60 * 60 * 1000L if (uptime > fiveHours) { - println("BackgroundStreamingService: Approaching time limit (${uptime / 3600000}h), stopping service") + println("BackgroundStreamingService: Time limit reached (${uptime / 3600000}h), stopping service") + // Notify Flutter before stopping + sendBroadcast(Intent(ACTION_TIME_LIMIT_APPROACHING).apply { + putExtra(EXTRA_REMAINING_MINUTES, 0) + }) stopStreaming() return } } - // Refresh wake lock to maintain CPU availability for streaming. - // Wake lock has 6-minute timeout, keepAlive is called every 5 minutes, - // ensuring continuous coverage with 1-minute overlap buffer. - // Note: Foreground services prevent process termination but NOT CPU sleep. - releaseWakeLock() - acquireWakeLock() - println("BackgroundStreamingService: Keep alive - wake lock refreshed, ${activeStreamCount} active streams") + // activeStreamCount reflects user-visible streams (excludes socket-keepalive) + if (activeStreamCount > 0) { + // Refresh wake lock to maintain CPU availability for actual streaming. + // Wake lock has 6-minute timeout, keepAlive is called every 5 minutes, + // ensuring continuous coverage with 1-minute overlap buffer. + // Note: Foreground services prevent process termination but NOT CPU sleep. + releaseWakeLock() + acquireWakeLock() + println("BackgroundStreamingService: Keep alive - wake lock refreshed, ${activeStreamCount} active streams") + } else { + // No active streams - just socket keepalive running. + // Foreground service keeps app alive; no wakelock needed. + releaseWakeLock() + println("BackgroundStreamingService: Keep alive (background task, no wakelock)") + } } private fun stopStreaming() { println("BackgroundStreamingService: Stopping service...") - activeStreams.clear() activeStreamCount = 0 releaseWakeLock() @@ -381,7 +407,6 @@ class BackgroundStreamingService : Service() { } } releaseWakeLock() - activeStreams.clear() activeStreamCount = 0 isForeground = false foregroundStartTime = 0 @@ -401,7 +426,8 @@ class BackgroundStreamingHandler(private val activity: MainActivity) : MethodCal private val streamsRequiringMic = mutableSetOf() private var backgroundJob: Job? = null private val scope = CoroutineScope(Dispatchers.Main + SupervisorJob()) - private var serviceFailureReceiver: android.content.BroadcastReceiver? = null + private var broadcastReceiver: android.content.BroadcastReceiver? = null + private var receiverRegistered = false companion object { private const val CHANNEL_NAME = "conduit/background_streaming" @@ -416,38 +442,72 @@ class BackgroundStreamingHandler(private val activity: MainActivity) : MethodCal sharedPrefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) createNotificationChannel() - setupServiceFailureReceiver() + setupBroadcastReceiver() } - private fun setupServiceFailureReceiver() { - serviceFailureReceiver = object : android.content.BroadcastReceiver() { + private fun hasNotificationPermission(): Boolean { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) return true + return ContextCompat.checkSelfPermission( + context, + Manifest.permission.POST_NOTIFICATIONS + ) == PackageManager.PERMISSION_GRANTED + } + + private fun setupBroadcastReceiver() { + if (receiverRegistered) return + + broadcastReceiver = object : android.content.BroadcastReceiver() { override fun onReceive(context: Context?, intent: Intent?) { - if (intent?.action == "app.cogwheel.conduit.FOREGROUND_SERVICE_FAILED") { - val error = intent.getStringExtra("error") ?: "Unknown error" - val errorType = intent.getStringExtra("errorType") ?: "Exception" + when (intent?.action) { + "app.cogwheel.conduit.FOREGROUND_SERVICE_FAILED" -> { + val error = intent.getStringExtra("error") ?: "Unknown error" + val errorType = intent.getStringExtra("errorType") ?: "Exception" + + println("BackgroundStreamingHandler: Service failure received: $errorType - $error") + + // Notify Flutter about the service failure + channel.invokeMethod("serviceFailed", mapOf( + "error" to error, + "errorType" to errorType, + "streamIds" to activeStreams.toList() + )) + + // Clear active streams since service failed + activeStreams.clear() + streamsRequiringMic.clear() + } - println("BackgroundStreamingHandler: Service failure received: $errorType - $error") + 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 + )) + } - // Notify Flutter about the service failure - channel.invokeMethod("serviceFailed", mapOf( - "error" to error, - "errorType" to errorType, - "streamIds" to activeStreams.toList() - )) - - // Clear active streams since service failed - activeStreams.clear() - streamsRequiringMic.clear() + BackgroundStreamingService.ACTION_MIC_PERMISSION_FALLBACK -> { + println("BackgroundStreamingHandler: Microphone permission fallback triggered") + channel.invokeMethod("microphonePermissionFallback", null) + } } } } - val filter = android.content.IntentFilter("app.cogwheel.conduit.FOREGROUND_SERVICE_FAILED") - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - context.registerReceiver(serviceFailureReceiver, filter, Context.RECEIVER_NOT_EXPORTED) - } else { - context.registerReceiver(serviceFailureReceiver, filter) + val filter = android.content.IntentFilter().apply { + addAction("app.cogwheel.conduit.FOREGROUND_SERVICE_FAILED") + addAction(BackgroundStreamingService.ACTION_TIME_LIMIT_APPROACHING) + addAction(BackgroundStreamingService.ACTION_MIC_PERMISSION_FALLBACK) } + + 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) { @@ -474,7 +534,8 @@ class BackgroundStreamingHandler(private val activity: MainActivity) : MethodCal } "keepAlive" -> { - keepAlive() + val streamCount = call.argument("streamCount") + keepAlive(streamCount) result.success(null) } @@ -493,6 +554,10 @@ class BackgroundStreamingHandler(private val activity: MainActivity) : MethodCal result.success(recoverStreamStates()) } + "checkNotificationPermission" -> { + result.success(hasNotificationPermission()) + } + else -> { result.notImplemented() } @@ -562,7 +627,10 @@ class BackgroundStreamingHandler(private val activity: MainActivity) : MethodCal backgroundJob?.cancel() backgroundJob = scope.launch { while (activeStreams.isNotEmpty()) { - delay(30000) // Check every 30 seconds + // Check every 5 minutes - matches Flutter keepAlive interval. + // This is a safety mechanism to clean up if Flutter fails to + // call stopBackgroundExecution (e.g., crash recovery). + delay(5 * 60 * 1000L) // Notify Dart side to check stream health channel.invokeMethod("checkStreams", null, object : MethodChannel.Result { @@ -594,15 +662,24 @@ class BackgroundStreamingHandler(private val activity: MainActivity) : MethodCal backgroundJob = null } - private fun keepAlive() { - if (activeStreams.isEmpty()) return + private fun keepAlive(userVisibleStreamCount: Int? = null) { + // Check local activeStreams to decide if service should run + // (includes socket-keepalive and other background tasks) + if (activeStreams.isEmpty()) { + stopForegroundService() + return + } + + // Use Flutter's user-visible stream count for logging (excludes socket-keepalive) + // Fall back to local count if not provided + val streamCount = userVisibleStreamCount ?: activeStreams.size try { val serviceIntent = Intent(context, BackgroundStreamingService::class.java) serviceIntent.action = "KEEP_ALIVE" serviceIntent.putExtra( BackgroundStreamingService.EXTRA_STREAM_COUNT, - activeStreams.size, + streamCount, ) serviceIntent.putExtra( BackgroundStreamingService.EXTRA_REQUIRES_MICROPHONE, @@ -708,13 +785,16 @@ class BackgroundStreamingHandler(private val activity: MainActivity) : MethodCal stopForegroundService() // Unregister broadcast receiver - try { - serviceFailureReceiver?.let { - context.unregisterReceiver(it) + if (receiverRegistered) { + try { + broadcastReceiver?.let { + context.unregisterReceiver(it) + } + } catch (e: Exception) { + println("BackgroundStreamingHandler: Error unregistering receiver: ${e.message}") } - } catch (e: Exception) { - println("BackgroundStreamingHandler: Error unregistering receiver: ${e.message}") + broadcastReceiver = null + receiverRegistered = false } - serviceFailureReceiver = null } } diff --git a/lib/core/providers/app_startup_providers.dart b/lib/core/providers/app_startup_providers.dart index 62923b4..0910481 100644 --- a/lib/core/providers/app_startup_providers.dart +++ b/lib/core/providers/app_startup_providers.dart @@ -451,7 +451,7 @@ class _SocketPersistenceObserver extends WidgetsBindingObserver { final Ref _ref; _SocketPersistenceObserver(this._ref); - static const String _socketId = 'socket-keepalive'; + static const String _socketId = BackgroundStreamingHandler.socketKeepaliveId; Timer? _heartbeat; bool _bgActive = false; bool _isBackgrounded = false; @@ -469,9 +469,11 @@ class _SocketPersistenceObserver extends WidgetsBindingObserver { if (!_shouldKeepAlive()) return; try { 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 = Timer.periodic(const Duration(seconds: 30), (_) async { + _heartbeat = Timer.periodic(const Duration(minutes: 5), (_) async { try { await BackgroundStreamingHandler.instance.keepAlive(); } catch (_) {} diff --git a/lib/core/services/background_streaming_handler.dart b/lib/core/services/background_streaming_handler.dart index 8168e90..44e1bb1 100644 --- a/lib/core/services/background_streaming_handler.dart +++ b/lib/core/services/background_streaming_handler.dart @@ -12,6 +12,10 @@ class BackgroundStreamingHandler { 'conduit/background_streaming', ); + /// Stream ID used for socket keepalive - not counted as an "active stream" + /// since it's a background task, not user-visible streaming. + static const String socketKeepaliveId = 'socket-keepalive'; + static BackgroundStreamingHandler? _instance; static BackgroundStreamingHandler get instance => _instance ??= BackgroundStreamingHandler._(); @@ -23,6 +27,10 @@ class BackgroundStreamingHandler { final Set _activeStreamIds = {}; final Map _streamStates = {}; + /// Returns count of actual content streams (excludes socket keepalive). + int get _userVisibleStreamCount => + _activeStreamIds.where((id) => id != socketKeepaliveId).length; + // Callbacks for platform-specific events void Function(List streamIds)? onStreamsSuspending; void Function()? onBackgroundTaskExpiring; @@ -33,6 +41,15 @@ class BackgroundStreamingHandler { void Function(String error, String errorType, List streamIds)? onServiceFailed; + /// Called when Android 14's foreground service time limit is reached. + /// The service stops after 5 hours (buffer before Android's 6-hour limit). + /// [remainingMinutes] will be 0 when this is called. + void Function(int remainingMinutes)? onBackgroundTimeLimitApproaching; + + /// Called when microphone permission was requested but not granted, + /// causing fallback to dataSync-only foreground service type. + void Function()? onMicrophonePermissionFallback; + void _setupMethodCallHandler() { _channel.setMethodCallHandler((call) async { switch (call.method) { @@ -106,6 +123,29 @@ class BackgroundStreamingHandler { _streamStates.remove(streamId); } break; + + case 'timeLimitApproaching': + final Map args = + call.arguments as Map; + 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 keepAlive() async { 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 { - 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'); } catch (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 checkNotificationPermission() async { + if (!Platform.isAndroid) return true; + + try { + final bool? hasPermission = await _channel.invokeMethod( + '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 Future> recoverStreamStates() async { if (!Platform.isIOS && !Platform.isAndroid) return [];