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.Context
import android.content.Intent
import android.net.Uri
import android.os.Build
import androidx.annotation.VisibleForTesting
import org.json.JSONObject
import java.io.File
import java.util.concurrent.CompletableFuture
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.TimeUnit
import java.util.concurrent.TimeoutException

data class HeadlessBridgeResult(
    val ok: Boolean,
    val state: String,
    val message: String?,
    // NTF-04 (Step 7a): cross-JNI recoverable/permanent discriminator surfaced
    // from the Rust HeadlessCoreReport. 7b consumes this to decide re-present
    // (recoverable) vs cancel (permanent) on a failed notification action.
    val recoverable: Boolean,
    val rawJson: String,
) {
    // AND-03: `ok` is the accept discriminator — `state` is opaque diagnostics.
    // Previously `accepted` hardcoded the three success state strings
    // ("running"/"setup_idle"/"locked_idle"); a host core returning
    // `ok=true,state="degraded"` was wrongly rejected. Treat any `ok=true` as
    // accepted and surface `state`/`message` as diagnostics only.
    val accepted: Boolean
        get() = ok

    companion object {
        fun fromJson(json: String): HeadlessBridgeResult {
            return try {
                val obj = JSONObject(json)
                HeadlessBridgeResult(
                    ok = obj.optBoolean("ok", false),
                    state = obj.optString("state", "failed"),
                    message = obj.optString("message").ifEmpty { null },
                    recoverable = obj.optBoolean("recoverable", false),
                    rawJson = json,
                )
            } catch (e: Exception) {
                failure("invalid_headless_core_response", e.message ?: e.javaClass.simpleName)
            }
        }

        fun failure(code: String, message: String): HeadlessBridgeResult {
            val json = JSONObject().apply {
                put("ok", false)
                put("state", "failed")
                put("code", code)
                put("message", message)
                put("recoverable", true)
            }.toString()
            return HeadlessBridgeResult(false, "failed", message, true, json)
        }
    }
}

object HeadlessBridge {
    @Volatile private var loaded = false
    @Volatile private var loadError: String? = null

    /**
     * Name of the host app's native (Rust cdylib) core to dlopen for headless
     * lifecycle / call / message bridging. The plugin ships NO native library;
     * a host app that bridges to a native core sets this
     * (`HeadlessBridge.nativeLibName = "app_core"`) AND exports the JNI symbols
     * `Java_app_tauri_backgroundservice_HeadlessBridge_*`
     * (`startCore`/`stopCore`/`notifyNetworkChanged`/`callAction`/`notificationAction`).
     *
     * When the library is absent — the default for consumers that don't bridge
     * to a native core — [ensureLoaded] fails gracefully and every entry point
     * returns a typed `native_library_load_failed` result; the lifecycle-only
     * path (foreground service + Rust `BackgroundService<R>` task) is unaffected.
     */
    @Volatile var nativeLibName: String = "app_core"

    // PRODUCT DECISION: headless message notifications carry a generic body (doc
    // 08, BGS-07). The webview/JS i18n catalog is absent on the force-stopped /
    // closed-webview boot, and the message body must not cross the JNI boundary
    // (non-payload, settled design §10); the sender is shown as the title.
    private const val HEADLESS_MESSAGE_BODY = "New message"

    @JvmStatic private external fun startCore(dataDir: String, reason: String): String
    @JvmStatic private external fun stopCore(dataDir: String, reason: String): String
    @JvmStatic private external fun notifyNetworkChanged(): String

    // M-NATIVE-1 (Step 9): native ring Answer/Decline → in-process Rust control
    // plane. `action` is "answer"/"reject"/"end"; routed to
    // core.answer_call/reject_call/end_call for `callId`. The notification
    // BroadcastReceiver and Telecom onAnswer/onReject reach this without a webview.
    @JvmStatic private external fun callAction(callId: String, action: String): String
    @JvmStatic
    private external fun notificationAction(
        // NTF-04 (Step 7a): dataDir first, mirroring startCore — a locked
        // notification action attempts a headless bring-up before failing.
        dataDir: String,
        action: String,
        chatId: String,
        messageId: String,
        replyText: String,
    ): String

    fun start(context: Context, reason: String): HeadlessBridgeResult {
        ensureLoaded()?.let { return HeadlessBridgeResult.failure("native_library_load_failed", it) }
        val dataDir = dataDir(context)
        if (!ensureDataDir(dataDir)) {
            return HeadlessBridgeResult.failure(
                "data_dir_unavailable",
                "Failed to create data directory: ${dataDir.absolutePath}",
            )
        }
        return HeadlessBridgeResult.fromJson(startCore(dataDir.absolutePath, reason))
    }

    fun stop(context: Context, reason: String): HeadlessBridgeResult {
        ensureLoaded()?.let { return HeadlessBridgeResult.failure("native_library_load_failed", it) }
        return HeadlessBridgeResult.fromJson(stopCore(dataDir(context).absolutePath, reason))
    }

    // Named networkChanged because the external above already takes the
    // notifyNetworkChanged JNI name (the symbol is the Rust-side contract).
    // May throw UnsatisfiedLinkError when the loaded lib predates the export.
    fun networkChanged(): HeadlessBridgeResult {
        ensureLoaded()?.let { return HeadlessBridgeResult.failure("native_library_load_failed", it) }
        return HeadlessBridgeResult.fromJson(notifyNetworkChanged())
    }

    /**
     * Drive a native ring action into the in-process headless Core (M-NATIVE-1,
     * Step 9). `action` is "answer"/"reject"/"end". Runs while the device is
     * locked / the webview is closed — the FGS-resident Core received the offer,
     * so the in-process JNI call reaches the same Core. Returns a report-shaped
     * result; a load failure or locked Core surfaces a typed recoverable failure
     * (never a crash) so the caller can log a diagnostic + terminal reason.
     *
     * Named `performCallAction` (not `callAction`) because the external above
     * already takes the `callAction` JNI name — the symbol is the Rust-side
     * contract (`Java_..._HeadlessBridge_callAction`), mirroring the
     * `notifyNetworkChanged`/`networkChanged` split.
     */
    fun performCallAction(callId: String, action: String): HeadlessBridgeResult {
        ensureLoaded()?.let { return HeadlessBridgeResult.failure("native_library_load_failed", it) }
        return HeadlessBridgeResult.fromJson(callAction(callId, action))
    }

    /**
     * Drive a native message-notification action (inline reply / mark-read) into
     * the in-process headless Core. `context` is resolved to the app data dir
     * internally (NTF-04, Step 7a — mirrors [start]) so a locked Core can
     * attempt a headless bring-up before failing; the caller (the action
     * dispatcher) already holds the context and needs no signature change beyond
     * passing it through. Returns a report-shaped result carrying the
     * recoverable discriminator 7b consumes to re-present vs cancel.
     */
    fun performNotificationAction(
        context: Context,
        action: String,
        chatId: String,
        messageId: String,
        replyText: String,
    ): HeadlessBridgeResult {
        ensureLoaded()?.let { return HeadlessBridgeResult.failure("native_library_load_failed", it) }
        val dataDir = dataDir(context)
        if (!ensureDataDir(dataDir)) {
            return HeadlessBridgeResult.failure(
                "data_dir_unavailable",
                "Failed to create data directory: ${dataDir.absolutePath}",
            )
        }
        return HeadlessBridgeResult.fromJson(
            notificationAction(dataDir.absolutePath, action, chatId, messageId, replyText)
        )
    }

    /**
     * Rust→Kotlin **upcall** target (Step 10, M-NATIVE-2 = NR-4): ring the
     * native incoming-call notification from the headless (webview-absent)
     * forwarder. The mirror of [performCallAction]'s downcall — invoked
     * in-process by the JNI `show_incoming_call_upcall` when a force-stopped /
     * closed-webview boot receives a `CoreEvent::IncomingCall` and no Tauri
     * activity/`AppHandle` exists. Resolves the process Application context
     * itself and delegates to [IncomingCallNotifier] (the same pure Android half
     * the plugin's `showIncomingCall` command uses). Best-effort: a missing
     * context logs and returns (never throws back across the JNI boundary).
     */
    @JvmStatic
    fun showIncomingCall(callId: String, callerName: String, hasVideo: Boolean) {
        val context = applicationContext()
        if (context == null) {
            android.util.Log.w("HeadlessBridge", "showIncomingCall: no application context")
            return
        }
        IncomingCallNotifier.showIncomingCall(
            context = context,
            callId = callId,
            callerName = callerName,
            isVideo = hasVideo,
            smallIcon = NotificationIconResolver.resolve(context),
            launchIntent = context.packageManager.getLaunchIntentForPackage(context.packageName),
        )
    }

    /**
     * Rust→Kotlin **upcall** (BGS-07, Step 10): post a native **message**
     * notification from the webview-absent forwarder — the mirror of
     * [showIncomingCall] for the message case. A message that arrives while the
     * webview is closed/force-stopped must not be silently stored with no
     * notification (the ring-only forwarder's gap). Resolves the process
     * Application context itself and delegates to [ActionableMessageNotifier].
     *
     * Diagnostics stay **non-payload** (settled design §10): only the routing
     * identity (`chatId`/`messageId` + `sender`) crosses the JNI boundary, never
     * the message body/media bytes — so the notification carries the sender as
     * title and a generic body, never the message text. Best-effort: a missing
     * context logs and returns (never throws back across JNI).
     */
    @JvmStatic
    fun showMessage(chatId: String, messageId: String, sender: String) {
        val context = applicationContext()
        if (context == null) {
            android.util.Log.w("HeadlessBridge", "showMessage: no application context")
            return
        }
        ActionableMessageNotifier.showMessageNotification(
            context = context,
            notificationId = ActionableMessageNotifier.notificationIdFor(chatId),
            chatId = chatId,
            messageId = messageId,
            // Non-payload: the sender routing identity is the only content that
            // crosses; the body stays generic (no message text is available here
            // and none may cross the JNI boundary).
            title = sender,
            body = HEADLESS_MESSAGE_BODY,
            routeUri = "bg-service://chat?chat_id=${Uri.encode(chatId)}&message_id=${Uri.encode(messageId)}",
            smallIcon = NotificationIconResolver.resolve(context),
            launchIntent = context.packageManager.getLaunchIntentForPackage(context.packageName),
        )
    }

    /**
     * Rust→Kotlin **upcall** (BGS-07, Step 10): cancel a stale CallStyle ring
     * for [callId] and revert the foreground-service type `phoneCall` →
     * `remoteMessaging` from the webview-absent forwarder — an abandoned/ended
     * incoming call must not leave the full-screen notification up forever.
     * Best-effort: a missing context logs and returns; the FGS revert is skipped
     * when no service is running (nothing to revert).
     */
    // CROSS-DOC: doc 04 owns call-control (ring lifecycle + FGS type).
    @JvmStatic
    fun cancelIncomingCall(callId: String) {
        val context = applicationContext()
        if (context == null) {
            android.util.Log.w("HeadlessBridge", "cancelIncomingCall: no application context")
            return
        }
        IncomingCallNotifier.cancel(context, callId)
        revertForegroundServiceType(context)
    }

    /**
     * Revert the running foreground service to the `remoteMessaging` baseline
     * after a call ends headlessly — the mirror of the plugin's
     * `updateForegroundServiceType` command, driven without a webview via an
     * [LifecycleService.ACTION_UPDATE_TYPE] intent. No-op when the service is not
     * running (nothing to revert). Best-effort: OS start restrictions are logged.
     */
    private fun revertForegroundServiceType(context: Context) {
        if (!LifecycleService.isRunning) return
        val intent = Intent(context, LifecycleService::class.java).apply {
            action = LifecycleService.ACTION_UPDATE_TYPE
            putExtra(LifecycleService.EXTRA_SERVICE_TYPE, "remoteMessaging")
        }
        try {
            if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
                context.startForegroundService(intent)
            } else {
                context.startService(intent)
            }
        } catch (e: Throwable) {
            android.util.Log.w(
                "HeadlessBridge",
                "cancelIncomingCall FGS revert failed: ${e.message}",
            )
        }
    }

    /**
     * The process Application context via `ActivityThread.currentApplication()`
     * — works in the FGS / cold-boot process where there is no Tauri activity,
     * and under Robolectric. Best-effort: null when unavailable.
     */
    private fun applicationContext(): Context? = try {
        val activityThread = Class.forName("android.app.ActivityThread")
        activityThread.getMethod("currentApplication").invoke(null) as? Context
    } catch (e: Throwable) {
        null
    }

    private fun ensureLoaded(): String? {
        if (loaded) return null
        synchronized(this) {
            if (loaded) return null
            loadError?.let { return it }
            return try {
                System.loadLibrary(nativeLibName)
                loaded = true
                null
            } catch (e: Throwable) {
                val message = e.message ?: e.javaClass.simpleName
                loadError = message
                message
            }
        }
    }

    @VisibleForTesting
    internal fun dataDir(context: Context): File {
        // AND-08: applicationInfo.dataDir can be null (early process init,
        // restored backups, or some host contexts). Fall back to filesDir,
        // which Android guarantees exists and is writable, so the headless
        // core always receives a real data root instead of File("null","data").
        val base = context.applicationInfo.dataDir ?: context.filesDir.absolutePath
        return File(base, "data")
    }

    private fun ensureDataDir(dir: File): Boolean =
        dir.exists() || dir.mkdirs()
}

object ServiceStartAckRegistry {
    data class Ack(val success: Boolean, val payload: String)

    private val pending = ConcurrentHashMap<String, CompletableFuture<Ack>>()

    fun register(id: String) {
        pending[id] = CompletableFuture()
    }

    fun complete(id: String?, success: Boolean, payload: String) {
        if (id == null) return
        pending.remove(id)?.complete(Ack(success, payload))
    }

    fun forget(id: String) {
        pending.remove(id)
    }

    fun await(id: String, timeoutMs: Long): Ack {
        val future = pending[id]
            ?: return Ack(false, errorJson("ack_missing", "No pending start acknowledgement"))
        return try {
            future.get(timeoutMs, TimeUnit.MILLISECONDS)
        } catch (_: TimeoutException) {
            Ack(false, errorJson("ack_timeout", "Timed out waiting for foreground service startup"))
        } catch (e: Exception) {
            Ack(false, errorJson("ack_failed", e.message ?: e.javaClass.simpleName))
        } finally {
            pending.remove(id)
        }
    }

    fun errorJson(code: String, message: String): String {
        return JSONObject().apply {
            put("ok", false)
            put("state", "failed")
            put("code", code)
            put("message", message)
            put("recoverable", true)
        }.toString()
    }
}