diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 39f12f8..0b0d227 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -31,9 +31,12 @@ + = Build.VERSION_CODES.Q) { - ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC - } else { - 0 - } - if (!isForeground) { - val notification = createMinimalNotification() - startForegroundInternal(notification, initialType) + // CRITICAL: Enter foreground IMMEDIATELY to satisfy Android's 5s timeout. + // Do this before ANY other initialization to minimize the risk of + // ForegroundServiceDidNotStartInTimeException. + try { + val initialType = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC + } else { + 0 + } + if (!isForeground) { + // Channel should already exist (created in ConduitApplication) + // but ensure it exists as a fallback + ensureNotificationChannel() + val notification = createMinimalNotification() + val success = startForegroundInternal(notification, initialType) + if (!success) { + // startForegroundInternal returned false (caught internal exception) + // Throw to trigger the fallback handler + throw IllegalStateException("startForegroundInternal returned false") + } + } + } catch (e: Exception) { + // Last resort: try to enter foreground with absolute minimal setup + println("BackgroundStreamingService: Error in onCreate, attempting fallback: ${e.message}") + try { + // Must ensure channel exists before creating notification on Android O+ + // Otherwise startForeground throws "Bad notification" error + ensureNotificationChannel() + val fallbackNotification = NotificationCompat.Builder(this, CHANNEL_ID) + .setContentTitle("Conduit") + .setSmallIcon(android.R.drawable.ic_dialog_info) + .setSilent(true) + .setOngoing(true) // Prevent user from dismissing foreground service notification + .build() + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + startForeground( + NOTIFICATION_ID, + fallbackNotification, + ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC + ) + currentForegroundType = ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC + } else { + @Suppress("DEPRECATION") + startForeground(NOTIFICATION_ID, fallbackNotification) + } + isForeground = true + foregroundStartTime = System.currentTimeMillis() + } catch (fallbackError: Exception) { + println("BackgroundStreamingService: Fallback also failed: ${fallbackError.message}") + // All attempts exhausted - now notify Flutter of the failure + // This ensures we don't prematurely notify before trying fallback + sendFailureNotification(fallbackError) + // Service will be killed by system, but at least we tried + } } } @@ -135,8 +179,8 @@ class BackgroundStreamingService : Service() { } catch (e: Exception) { // Catch all exceptions including ForegroundServiceStartNotAllowedException println("BackgroundStreamingService: Failed to enter foreground: ${e.javaClass.simpleName}: ${e.message}") - // Notify Flutter about the failure - sendFailureNotification(e) + // Don't notify Flutter here - let caller handle fallback attempts first. + // Only notify after all attempts (primary + fallback) have been exhausted. false } } @@ -221,6 +265,18 @@ class BackgroundStreamingService : Service() { manager.createNotificationChannel(channel) } + /** + * 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 + * interval with a 1-minute buffer. This ensures continuous wake lock coverage + * without gaps between refreshes. + * + * Note: Android Play Console may flag wake locks > 1 minute as "excessive", + * but continuous CPU availability is required for reliable streaming. + * The alternative (60-second timeout with 5-minute refresh) creates 4-minute + * gaps where the CPU can sleep, causing streams to stall. + */ private fun acquireWakeLock() { if (wakeLock?.isHeld == true) return @@ -229,19 +285,26 @@ class BackgroundStreamingService : Service() { PowerManager.PARTIAL_WAKE_LOCK, "Conduit::StreamingWakeLock" ).apply { - // Use shorter wake lock duration to comply with Android restrictions - // Refresh periodically via keepAlive instead of long timeout - acquire(10 * 60 * 1000L) // 10 minutes (refreshed every 5 minutes) + // 6-minute timeout covers the 5-minute keepAlive interval + 1-minute buffer + // This ensures no gaps in wake lock coverage during active streaming + // Note: Use default reference-counted mode with timeout-based acquire + // (setReferenceCounted(false) interferes with timeout auto-release) + acquire(6 * 60 * 1000L) // 6 minutes - refreshed every 5 minutes by keepAlive() } - println("BackgroundStreamingService: Wake lock acquired") + println("BackgroundStreamingService: Wake lock acquired (6min timeout)") } private fun releaseWakeLock() { - wakeLock?.let { - if (it.isHeld) { - it.release() - println("BackgroundStreamingService: Wake lock released") + try { + wakeLock?.let { + if (it.isHeld) { + it.release() + println("BackgroundStreamingService: Wake lock released") + } } + } catch (e: Exception) { + // Wake lock may already be released due to timeout + println("BackgroundStreamingService: Wake lock release exception: ${e.message}") } wakeLock = null } @@ -264,10 +327,13 @@ class BackgroundStreamingService : Service() { } } - // Refresh wake lock to extend background processing time + // 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") + println("BackgroundStreamingService: Keep alive - wake lock refreshed, ${activeStreamCount} active streams") } private fun stopStreaming() { diff --git a/android/app/src/main/kotlin/app/cogwheel/conduit/ConduitApplication.kt b/android/app/src/main/kotlin/app/cogwheel/conduit/ConduitApplication.kt new file mode 100644 index 0000000..fd40d49 --- /dev/null +++ b/android/app/src/main/kotlin/app/cogwheel/conduit/ConduitApplication.kt @@ -0,0 +1,75 @@ +package app.cogwheel.conduit + +import android.app.Application +import android.app.Notification +import android.app.NotificationChannel +import android.app.NotificationManager +import android.content.Context +import android.os.Build + +/** + * Custom Application class to perform early initialization tasks. + * + * Most importantly, this creates notification channels at app startup + * to avoid ForegroundServiceDidNotStartInTimeException. Android requires + * foreground services to call startForeground() within 5-10 seconds, + * and having the notification channel ready beforehand prevents delays. + */ +class ConduitApplication : Application() { + + override fun onCreate() { + super.onCreate() + // Create notification channels immediately at app startup + // This ensures channels exist before any service tries to use them + createNotificationChannels() + } + + private fun createNotificationChannels() { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return + + val notificationManager = + getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + + // Background streaming service channel + createChannelIfNeeded( + notificationManager, + channelId = BackgroundStreamingService.CHANNEL_ID, + channelName = "Background Service", + description = "Background service for Conduit", + importance = NotificationManager.IMPORTANCE_MIN, + ) + + // Voice call notification channel (used by VoiceCallNotificationService) + createChannelIfNeeded( + notificationManager, + channelId = "voice_call_channel", + channelName = "Voice Call", + description = "Ongoing voice call notifications", + importance = NotificationManager.IMPORTANCE_HIGH, + ) + } + + private fun createChannelIfNeeded( + manager: NotificationManager, + channelId: String, + channelName: String, + description: String, + importance: Int, + ) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return + if (manager.getNotificationChannel(channelId) != null) return + + val channel = NotificationChannel(channelId, channelName, importance).apply { + this.description = description + setShowBadge(false) + enableLights(false) + enableVibration(false) + setSound(null, null) + lockscreenVisibility = Notification.VISIBILITY_SECRET + } + + manager.createNotificationChannel(channel) + } +} + +