tauri-plugin-background-service 1.0.1

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

import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.os.Build
import android.os.Handler
import android.os.Looper
import android.util.Log
import androidx.annotation.VisibleForTesting
import java.util.concurrent.ConcurrentHashMap

/**
 * Native incoming-call notifier (spec 08 C6, Step 15).
 *
 * Fires the high-priority `CallStyle` notification when a call offer arrives
 * headlessly (the webview is closed and the headless core under the
 * `remoteMessaging` FGS received the offer). The notification:
 *
 * - Uses the dedicated RINGTONE channel so the device rings.
 * - Attaches a full-screen intent when `USE_FULL_SCREEN_INTENT` is granted,
 *   so a locked-screen device shows the native incoming-call surface.
 * - Falls back (F4) to the same `CallStyle` notification — still ringing via
 *   the channel — when the full-screen-intent grant is missing or revoked,
 *   and surfaces a Settings deep-link during call onboarding.
 *
 * The trigger is the Rust event layer (Tauri), which calls the plugin command
 * wired in [BackgroundServicePlugin]; this object is the pure Android half and
 * is JVM/Robolectric-testable.
 */
object IncomingCallNotifier {

    private const val TAG = "IncomingCallNotifier"
    const val CALL_NOTIFICATION_BASE = 9100

    /**
     * Ring auto-cancel timeout (BGS-07, Step 10). An abandoned incoming call
     * whose caller gives up before the headless forwarder sees a `CallEnded`
     * must not leave the full-screen CallStyle notification ringing forever, so
     * the notification is auto-cancelled after this delay.
     */
    // PRODUCT DECISION: 60s headless incoming-ring timeout (doc 08, BGS-07)
    const val RING_TIMEOUT_MS = 60_000L

    /**
     * Testable timeout seam. Schedules [action] after [delayMs] and returns a
     * canceler that drops the pending run. Production posts to a main-looper
     * [Handler]; tests inject a manual scheduler so the ring timeout fires
     * deterministically with no wall-clock sleep.
     */
    fun interface TimeoutScheduler {
        fun schedule(delayMs: Long, action: () -> Unit): () -> Unit
    }

    @VisibleForTesting
    internal var timeoutScheduler: TimeoutScheduler = TimeoutScheduler { delayMs, action ->
        val handler = Handler(Looper.getMainLooper())
        val runnable = Runnable { action() }
        handler.postDelayed(runnable, delayMs)
        ({ handler.removeCallbacks(runnable) })
    }

    /** Pending ring-timeout cancelers, keyed by call id. */
    private val ringTimeouts = ConcurrentHashMap<String, () -> Unit>()

    /** Deep-link extra carried on the launch intent into the webview. */
    const val EXTRA_CALL_ID = "bg_service.call_id"
    const val EXTRA_CALL_ACTION = "bg_service.call_action"
    const val ACTION_ANSWER = "answer"
    const val ACTION_DECLINE = "decline"

    /**
     * Whether this app may post a full-screen intent (spec 08 C6 F4).
     *
     * `USE_FULL_SCREEN_INTENT` is granted by default for apps with calling/
     * notification responsibilities; on API 34+ the user (or the system) may
     * revoke it, in which case the notification falls back to CallStyle +
     * ringtone. Pre-API-29 the concept does not exist; pre-API-34 the grant is
     * effectively always present once the permission is declared.
     */
    @VisibleForTesting
    fun canUseFullScreenIntent(context: Context): Boolean {
        if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) return false
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
            val nm = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
            return nm.canUseFullScreenIntent()
        }
        // API 29–33: auto-granted when the permission is declared.
        return true
    }

    /** Stable, non-negative per-call notification id. */
    fun notificationIdFor(callId: String): Int {
        val hash = callId.hashCode()
        return CALL_NOTIFICATION_BASE + (if (hash < 0) hash.inv() else hash)
    }

    /**
     * Show the incoming-call notification. Computes the full-screen-intent
     * eligibility via [canUseFullScreenIntent].
     */
    fun showIncomingCall(
        context: Context,
        callId: String,
        callerName: CharSequence,
        isVideo: Boolean,
        smallIcon: Int,
        launchIntent: Intent?,
    ) {
        showIncomingCall(
            context, callId, callerName, isVideo, smallIcon, launchIntent,
            useFullScreenIntent = canUseFullScreenIntent(context),
        )
    }

    /**
     * Show the incoming-call notification with an explicit full-screen-intent
     * flag — the testable seam for the F4 fallback branch.
     */
    @VisibleForTesting
    fun showIncomingCall(
        context: Context,
        callId: String,
        callerName: CharSequence,
        isVideo: Boolean,
        smallIcon: Int,
        launchIntent: Intent?,
        useFullScreenIntent: Boolean,
    ) {
        NotificationHelper.ensureCallChannel(context)

        val fullScreenIntent = fullScreenPendingIntent(context, callId, launchIntent)
        val answerIntent = callActionPendingIntent(context, callId, ACTION_ANSWER, launchIntent)
        val declineIntent = callActionPendingIntent(context, callId, ACTION_DECLINE, launchIntent)

        val notification = NotificationHelper.buildIncomingCallNotification(
            context = context,
            channelId = NotificationHelper.CALL_CHANNEL_ID,
            callerName = callerName,
            isVideo = isVideo,
            smallIcon = smallIcon,
            fullScreenIntent = fullScreenIntent,
            answerIntent = answerIntent,
            declineIntent = declineIntent,
            useFullScreenIntent = useFullScreenIntent,
        )

        val nm = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
        nm.notify(notificationIdFor(callId), notification)
        Log.i(TAG, "showIncomingCall: callId=$callId, video=$isVideo, fsi=$useFullScreenIntent")

        // BGS-07 (Step 10): arm the auto-cancel timeout so an abandoned call
        // does not ring forever. Use the application context — the timeout may
        // outlive the caller's (possibly Activity) context.
        scheduleRingTimeout(context.applicationContext ?: context, callId)
    }

    /** Cancel the incoming-call notification for [callId]. */
    fun cancel(context: Context, callId: String) {
        // Drop any pending auto-cancel timeout first (call answered / declined /
        // ended, or a re-ring for the same id) so a stale timeout cannot cancel
        // a fresh notification (BGS-07, Step 10).
        ringTimeouts.remove(callId)?.invoke()
        val nm = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
        nm.cancel(notificationIdFor(callId))
        Log.i(TAG, "cancel: callId=$callId")
    }

    /**
     * Arm the ring auto-cancel timeout for [callId] (BGS-07, Step 10). Replaces
     * any prior timeout for the same call id; on fire it clears its own
     * bookkeeping and cancels the ring via [cancel].
     */
    private fun scheduleRingTimeout(context: Context, callId: String) {
        ringTimeouts.remove(callId)?.invoke()
        val canceler = timeoutScheduler.schedule(RING_TIMEOUT_MS) {
            // Remove our own entry before cancelling so cancel()'s canceler
            // lookup is a no-op (the timeout already fired).
            ringTimeouts.remove(callId)
            Log.i(TAG, "ring timeout: auto-cancel callId=$callId")
            cancel(context, callId)
        }
        ringTimeouts[callId] = canceler
    }

    /** Settings deep-link to the app's notification page (call onboarding, F4). */
    fun openFullScreenIntentSettings(context: Context) {
        if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) return
        val intent = Intent(
            if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
                android.provider.Settings.ACTION_MANAGE_APP_USE_FULL_SCREEN_INTENT
            else
                android.provider.Settings.ACTION_APP_NOTIFICATION_SETTINGS
        ).apply {
            if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
                data = Uri.parse("package:${context.packageName}")
            } else {
                putExtra(android.provider.Settings.EXTRA_APP_PACKAGE, context.packageName)
            }
            addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
        }
        try {
            context.startActivity(intent)
        } catch (e: Exception) {
            Log.w(TAG, "Could not open full-screen-intent settings: ${e.message}")
        }
    }

    private fun baseLaunchIntent(context: Context, callId: String, launchIntent: Intent?): Intent {
        val base = launchIntent ?: context.packageManager.getLaunchIntentForPackage(context.packageName)
            ?: Intent()
        return Intent(base).apply {
            addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP)
            putExtra(EXTRA_CALL_ID, callId)
        }
    }

    private fun fullScreenPendingIntent(
        context: Context,
        callId: String,
        launchIntent: Intent?,
    ): PendingIntent? {
        val intent = baseLaunchIntent(context, callId, launchIntent)
        return PendingIntent.getActivity(
            context,
            // Unique request code per call so the FSI targets this call.
            notificationIdFor(callId),
            intent,
            PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
        )
    }

    /**
     * Build the Answer/Decline action [PendingIntent] (M-NATIVE-1, Step 9).
     *
     * This is a **`getBroadcast`** intent targeting [CallActionReceiver] — NOT a
     * `getActivity` launcher. The broadcast runs while the device is locked / the
     * webview is closed, so the receiver can drive the Rust control plane
     * (`answer_call`/`reject_call`) directly. The prior `getActivity` form merely
     * opened the app and left `EXTRA_CALL_ACTION` unconsumed (the inert ring).
     *
     * The full-screen / content intents stay activity launchers (the ringing
     * surface); only the **answer/decline** action must reach the core.
     */
    @VisibleForTesting
    internal fun callActionPendingIntent(
        context: Context,
        callId: String,
        action: String,
        @Suppress("UNUSED_PARAMETER") launchIntent: Intent?,
    ): PendingIntent {
        val intent = Intent(context, CallActionReceiver::class.java).apply {
            this.action = CallActionReceiver.ACTION_CALL_ACTION
            putExtra(EXTRA_CALL_ID, callId)
            putExtra(EXTRA_CALL_ACTION, action)
        }
        // Distinct request codes keep answer/decline intents independent.
        val requestCode = notificationIdFor(callId) + if (action == ACTION_ANSWER) 1 else 2
        return PendingIntent.getBroadcast(
            context,
            requestCode,
            intent,
            PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
        )
    }
}