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:
@@ -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"
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
Reference in New Issue
Block a user