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.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.util.Log
import androidx.annotation.VisibleForTesting

/**
 * Native ring Answer/Decline → Rust control plane (spec 08 / M-NATIVE-1, Step 9).
 *
 * The injectable seam that routes a notification/Telecom call action to the
 * in-process headless Core. Default = [RealCallActionDispatcher] (JNI). The
 * Robolectric host gate swaps in a fake to assert the action→core dispatch
 * (`answer_call`/`reject_call`) WITHOUT loading the native lib — the masked seam
 * the prior activity-launcher intents never reached.
 */
interface CallActionDispatcher {
    fun answerCall(context: Context, callId: String)
    fun rejectCall(context: Context, callId: String)
    fun endCall(context: Context, callId: String)
}

/**
 * Production dispatcher: routes to the in-process headless Core via the JNI
 * bridge ([HeadlessBridge.performCallAction]). The Core runs in this process
 * under the call FGS — it received the offer that rang the device — so the hop
 * reaches the same Core with **no webview loaded**, while the device is locked.
 * A failure surfaces as a logged diagnostic + terminal reason (non-payload).
 */
class RealCallActionDispatcher : CallActionDispatcher {
    override fun answerCall(context: Context, callId: String) = dispatch(callId, "answer")
    override fun rejectCall(context: Context, callId: String) = dispatch(callId, "reject")
    override fun endCall(context: Context, callId: String) = dispatch(callId, "end")

    private fun dispatch(callId: String, action: String) {
        val result = HeadlessBridge.performCallAction(callId, action)
        if (!result.ok) {
            Log.w(TAG, "callAction '$action' for $callId did not reach core: ${result.message}")
        }
    }

    companion object {
        private const val TAG = "CallActionDispatcher"
    }
}

/**
 * Process-level holder for the active [CallActionDispatcher].
 *
 * A [BroadcastReceiver] is framework-instantiated (no DI), so the injectable
 * seam lives here — mirrors `BackgroundServicePlugin`'s companion-level
 * `onTimeoutEvent` injection. The Robolectric gate overrides [dispatcher] with a
 * fake; production keeps the JNI [RealCallActionDispatcher].
 */
object CallActionDispatch {
    @Volatile
    var dispatcher: CallActionDispatcher = RealCallActionDispatcher()
}

/**
 * Receives the notification Answer/Decline broadcast and drives the Rust control
 * plane (M-NATIVE-1, Step 9).
 *
 * This is the load-bearing native→core binding: the notification's Answer/Decline
 * `PendingIntent`s ([IncomingCallNotifier.callActionPendingIntent]) are now
 * `getBroadcast` intents targeting this receiver (previously `getActivity`
 * launchers that merely opened the app). `onReceive` runs while the device is
 * locked / the webview is closed, reads the carried `call_id` + action, dispatches
 * to the core (`answer_call`/`reject_call`) via the injectable seam, then cancels
 * the ring.
 */
class CallActionReceiver : BroadcastReceiver() {
    override fun onReceive(context: Context, intent: Intent) {
        val callId = intent.getStringExtra(IncomingCallNotifier.EXTRA_CALL_ID) ?: return
        val action = intent.getStringExtra(IncomingCallNotifier.EXTRA_CALL_ACTION) ?: return

        if (action != IncomingCallNotifier.ACTION_ANSWER && action != IncomingCallNotifier.ACTION_DECLINE) {
            Log.w(TAG, "unknown call action '$action' (callId=$callId)")
            return
        }

        // BGS-20 (doc-08 Step 11): move the call-action JNI hop OFF the main
        // looper. `BroadcastReceiver.onReceive` runs on the main thread; the
        // dispatcher hop reaches HeadlessBridge.performCallAction → lib.rs
        // block_on(call_action) (incl. a fresh QUIC dial), which ANRs if it runs
        // inline while the user taps Answer/Decline from the lock-screen
        // notification. goAsync() acquires the broadcast's PendingResult so the
        // system keeps the receiver alive while a worker runs the JNI;
        // PendingResult.finish() is called exactly once in the finally below
        // (and on exception, so it is never leaked). Mirrors LifecycleService's
        // coreStopExecutor discipline (Step 11 Task 1). The notification-answer→
        // Telecom bridge (markCallActive) + ring cancel stay inside the same
        // goAsync block for a consistent answer path; the off-main test asserts
        // only the dispatcher thread.
        //
        // The action→core routing remains the masked seam (Step 9, AC5): no-op
        // answerCall/rejectCall and the routing test goes RED while
        // load_registersSelfManagedPhoneAccount stays GREEN.
        val pendingResult = pendingResultOrNoop()
        actionExecutor("bg-call-action") {
            try {
                when (action) {
                    IncomingCallNotifier.ACTION_ANSWER -> {
                        CallActionDispatch.dispatcher.answerCall(context, callId)
                        // M-NATIVE-3 (Step 11): bridge the notification-answer
                        // (our primary answer surface) to the live Telecom
                        // connection so it goes ACTIVE. No-op when no self-managed
                        // connection is live for this call.
                        BackgroundCallConnectionService.markCallActive(callId)
                    }
                    IncomingCallNotifier.ACTION_DECLINE -> CallActionDispatch.dispatcher.rejectCall(context, callId)
                }
                // The ring is consumed by the action — clear the CallStyle
                // notification.
                IncomingCallNotifier.cancel(context, callId)
                Log.i(TAG, "dispatched '$action' for callId=$callId")
            } catch (t: Throwable) {
                Log.e(TAG, "call action dispatch failed for callId=$callId action='$action'", t)
            } finally {
                pendingResult?.finish()
            }
        }
    }

    /**
     * goAsync() can return null or throw when the broadcast has already been
     * finalized (a second call, or direct unit-test invocation with no framework
     * PendingResult). Treat that as "no async lifecycle to manage" — the dispatch
     * still runs on the executor; finish() is a guarded no-op for null.
     */
    private fun pendingResultOrNoop(): BroadcastReceiver.PendingResult? =
        runCatching { goAsync() }.getOrNull()

    companion object {
        private const val TAG = "CallActionReceiver"

        /** Explicit broadcast action carried by the notification Answer/Decline intents. */
        const val ACTION_CALL_ACTION = "app.tauri.backgroundservice.CALL_ACTION"

        /**
         * BGS-20 (doc-08 Step 11): runs the call-action dispatch on a worker so
         * the JNI hop stays off the main looper. Default spawns a real thread
         * (fire-and-forget, like LifecycleService.coreStopExecutor); tests inject
         * an inline executor for determinism, except the off-main test which
         * installs a thread-distinguishing executor.
         */
        internal val DEFAULT_ACTION_EXECUTOR: (String, () -> Unit) -> Unit = { name, task ->
            Thread({ task() }, name).start()
        }

        @VisibleForTesting
        internal var actionExecutor: (String, () -> Unit) -> Unit = DEFAULT_ACTION_EXECUTOR
    }
}