Merge pull request #204 from cogwheel0/android-custom-application-notification-channels

feat(android): Create custom application class for notification channels
This commit is contained in:
cogwheel
2025-12-01 19:41:32 +05:30
committed by GitHub
3 changed files with 167 additions and 23 deletions

View File

@@ -31,9 +31,12 @@
</intent> </intent>
</queries> </queries>
<!-- Custom Application class creates notification channels at startup to prevent
ForegroundServiceDidNotStartInTimeException. This ensures channels exist before
any foreground service attempts to use them. -->
<application <application
android:label="Conduit" android:label="Conduit"
android:name="${applicationName}" android:name=".ConduitApplication"
android:icon="@mipmap/ic_launcher" android:icon="@mipmap/ic_launcher"
android:allowBackup="false" android:allowBackup="false"
android:fullBackupContent="false" android:fullBackupContent="false"

View File

@@ -44,16 +44,60 @@ class BackgroundStreamingService : Service() {
super.onCreate() super.onCreate()
println("BackgroundStreamingService: Service created") println("BackgroundStreamingService: Service created")
// Enter foreground immediately to satisfy Android's 5s requirement even // CRITICAL: Enter foreground IMMEDIATELY to satisfy Android's 5s timeout.
// if onStartCommand handling is delayed or cancelled. // Do this before ANY other initialization to minimize the risk of
val initialType = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { // ForegroundServiceDidNotStartInTimeException.
ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC try {
} else { val initialType = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
0 ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC
} } else {
if (!isForeground) { 0
val notification = createMinimalNotification() }
startForegroundInternal(notification, initialType) 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 (e: Exception) {
// Catch all exceptions including ForegroundServiceStartNotAllowedException // Catch all exceptions including ForegroundServiceStartNotAllowedException
println("BackgroundStreamingService: Failed to enter foreground: ${e.javaClass.simpleName}: ${e.message}") println("BackgroundStreamingService: Failed to enter foreground: ${e.javaClass.simpleName}: ${e.message}")
// Notify Flutter about the failure // Don't notify Flutter here - let caller handle fallback attempts first.
sendFailureNotification(e) // Only notify after all attempts (primary + fallback) have been exhausted.
false false
} }
} }
@@ -221,6 +265,18 @@ class BackgroundStreamingService : Service() {
manager.createNotificationChannel(channel) 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() { private fun acquireWakeLock() {
if (wakeLock?.isHeld == true) return if (wakeLock?.isHeld == true) return
@@ -229,19 +285,26 @@ class BackgroundStreamingService : Service() {
PowerManager.PARTIAL_WAKE_LOCK, PowerManager.PARTIAL_WAKE_LOCK,
"Conduit::StreamingWakeLock" "Conduit::StreamingWakeLock"
).apply { ).apply {
// Use shorter wake lock duration to comply with Android restrictions // 6-minute timeout covers the 5-minute keepAlive interval + 1-minute buffer
// Refresh periodically via keepAlive instead of long timeout // This ensures no gaps in wake lock coverage during active streaming
acquire(10 * 60 * 1000L) // 10 minutes (refreshed every 5 minutes) // 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() { private fun releaseWakeLock() {
wakeLock?.let { try {
if (it.isHeld) { wakeLock?.let {
it.release() if (it.isHeld) {
println("BackgroundStreamingService: Wake lock released") 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 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() releaseWakeLock()
acquireWakeLock() acquireWakeLock()
println("BackgroundStreamingService: Keep alive - wake lock refreshed") println("BackgroundStreamingService: Keep alive - wake lock refreshed, ${activeStreamCount} active streams")
} }
private fun stopStreaming() { private fun stopStreaming() {

View File

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