tauri-plugin-background-service 0.7.0

Background service lifecycle plugin for Tauri v2 — run long-lived tasks on Android, iOS, and desktop
Documentation
package app.tauri.backgroundservice

import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.os.Build
import androidx.core.app.NotificationCompat

class BootReceiver : BroadcastReceiver() {

    companion object {
        const val RECOVERY_CHANNEL_ID = "bg_service_recovery"
        const val RECOVERY_NOTIFICATION_ID = 9002

        // FGS types blocked from BOOT_COMPLETED receiver on API 35+
        // See: https://developer.android.com/about/versions/15/behavior-changes-15
        private val BOOT_BLOCKED_TYPES_API35 = setOf(
            "dataSync",
            "camera",
            "mediaPlayback",
            "phoneCall",
            "mediaProjection",
            "microphone",
        )

        fun isBootBlockedType(serviceType: String, apiLevel: Int): Boolean {
            if (apiLevel < 35) return false
            return serviceType in BOOT_BLOCKED_TYPES_API35
        }

        fun postRecoveryNotification(context: Context, label: String) {
            val nm = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager

            val channel = NotificationChannel(
                RECOVERY_CHANNEL_ID,
                "Service Recovery",
                NotificationManager.IMPORTANCE_HIGH,
            ).apply {
                description = "Notifications to resume background service after reboot"
                setShowBadge(true)
            }
            nm.createNotificationChannel(channel)

            val pendingIntent = context.packageManager
                .getLaunchIntentForPackage(context.packageName)
                ?.let {
                    PendingIntent.getActivity(
                        context,
                        0,
                        it.apply {
                            addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP)
                        },
                        PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
                    )
                }

            val notification = NotificationCompat.Builder(context, RECOVERY_CHANNEL_ID)
                .setContentTitle(context.applicationInfo.loadLabel(context.packageManager))
                .setContentText("Tap to resume: $label")
                .setSmallIcon(android.R.drawable.stat_notify_sync)
                .setOngoing(true)
                .setPriority(NotificationCompat.PRIORITY_HIGH)
                .apply { pendingIntent?.let { setContentIntent(it) } }
                .build()

            nm.notify(RECOVERY_NOTIFICATION_ID, notification)
        }
    }

    override fun onReceive(context: Context, intent: Intent) {
        when (intent.action) {
            Intent.ACTION_LOCKED_BOOT_COMPLETED -> {
                // Cannot read credential-encrypted SharedPreferences in direct-boot mode
                return
            }
            Intent.ACTION_BOOT_COMPLETED -> handleBootCompleted(context)
            Intent.ACTION_MY_PACKAGE_REPLACED -> handleMyPackageReplaced(context)
        }
    }

    private fun handleBootCompleted(context: Context) {
        val state = DurableState.load(context)
        if (!state.desiredRunning) return

        if (isBootBlockedType(state.lastServiceType, Build.VERSION.SDK_INT)) {
            DurableState.save(context, state.copy(
                recoveryPending = true,
                recoveryReason = "boot_fgs_type_restricted",
            ))
            postRecoveryNotification(context, state.lastServiceLabel)
            return
        }

        startRecoveryService(context, state.lastServiceLabel, state.lastServiceType)
    }

    private fun handleMyPackageReplaced(context: Context) {
        val state = DurableState.load(context)
        if (!state.desiredRunning) return

        // MY_PACKAGE_REPLACED is not subject to boot-time FGS type restrictions
        startRecoveryService(context, state.lastServiceLabel, state.lastServiceType)
    }

    private fun startRecoveryService(context: Context, label: String, serviceType: String) {
        val intent = Intent(context, LifecycleService::class.java).apply {
            action = LifecycleService.ACTION_START
            putExtra(LifecycleService.EXTRA_LABEL, label)
            putExtra(LifecycleService.EXTRA_SERVICE_TYPE, serviceType)
        }
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
            context.startForegroundService(intent)
        } else {
            context.startService(intent)
        }
    }
}