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.content.Context
import androidx.test.core.app.ApplicationProvider
import org.junit.Assert.*
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.Shadows.shadowOf
import org.robolectric.annotation.Config

/**
 * JVM tests for [IncomingCallNotifier] (spec 08 C6, Step 15):
 * - `canUseFullScreenIntent` API-level branches (the F4 gate).
 * - showIncomingCall posts to the dedicated call channel.
 * - F4 fallback: when the full-screen-intent grant is missing, the posted
 *   notification carries no full-screen intent (it still rings via the channel).
 */
@RunWith(RobolectricTestRunner::class)
class IncomingCallNotifierTest {

    private lateinit var context: Context

    @Before
    fun setup() {
        context = ApplicationProvider.getApplicationContext()
    }

    @Test
    @Config(sdk = [24])
    fun canUseFullScreenIntent_belowQ_false() {
        // Pre-API-29 the concept does not exist.
        assertFalse(IncomingCallNotifier.canUseFullScreenIntent(context))
    }

    @Test
    @Config(sdk = [33])
    fun canUseFullScreenIntent_api29to33_true() {
        // API 29–33: auto-granted when the permission is declared.
        assertTrue(IncomingCallNotifier.canUseFullScreenIntent(context))
    }

    @Test
    fun notificationIdFor_isStableAndNonNegative() {
        val id1 = IncomingCallNotifier.notificationIdFor("call-abc")
        val id2 = IncomingCallNotifier.notificationIdFor("call-abc")
        val idOther = IncomingCallNotifier.notificationIdFor("call-xyz")
        assertEquals("Same call id → same notification id", id1, id2)
        assertNotEquals("Different call id → different notification id", id1, idOther)
        assertTrue("Notification ids must be positive", id1 > 0)
        assertTrue(
            "Notification id must sit above the bg-service ids (no clash)",
            id1 >= IncomingCallNotifier.CALL_NOTIFICATION_BASE,
        )
    }

    @Test
    @Config(sdk = [34])
    fun showIncomingCall_postsToCallChannel() {
        IncomingCallNotifier.showIncomingCall(
            context = context,
            callId = "call-1",
            callerName = "Alice",
            isVideo = false,
            smallIcon = android.R.drawable.stat_notify_sync,
            launchIntent = null,
            useFullScreenIntent = true,
        )

        val nm = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
        val shadowNm = shadowOf(nm)
        val notif = shadowNm.getNotification(IncomingCallNotifier.notificationIdFor("call-1"))
        assertNotNull("Incoming call notification should be posted", notif)
        assertEquals(NotificationHelper.CALL_CHANNEL_ID, notif!!.channelId)
        assertNotNull("FSI granted → full-screen intent attached", notif.fullScreenIntent)

        // The dedicated channel must have been created.
        val channel = nm.getNotificationChannel(NotificationHelper.CALL_CHANNEL_ID)
        assertNotNull("Call channel created on show", channel)
    }

    @Test
    @Config(sdk = [34])
    fun showIncomingCall_f4Fallback_hasNoFullScreenIntent() {
        // F4: full-screen-intent grant revoked → ring via channel, no FSI.
        IncomingCallNotifier.showIncomingCall(
            context = context,
            callId = "call-2",
            callerName = "Bob",
            isVideo = true,
            smallIcon = android.R.drawable.stat_notify_sync,
            launchIntent = null,
            useFullScreenIntent = false,
        )

        val nm = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
        val notif = shadowOf(nm).getNotification(IncomingCallNotifier.notificationIdFor("call-2"))
        assertNotNull(notif)
        assertNull("F4 fallback: no full-screen intent", notif!!.fullScreenIntent)
        assertEquals(
            "Fallback still uses the ringing call channel",
            NotificationHelper.CALL_CHANNEL_ID,
            notif.channelId,
        )
    }

    // ── M-NATIVE-1 (Step 9): Answer/Decline actions are broadcasts to the core ──

    @Test
    @Config(sdk = [34])
    fun callAction_answer_isBroadcastToCallActionReceiver() {
        // The masked seam: the Answer action must be a getBroadcast PendingIntent
        // targeting CallActionReceiver (runs locked / webview-absent), NOT a
        // getActivity launcher that merely opens the app. RED on the prior
        // getActivity form; GREEN once switched to getBroadcast.
        val pi = IncomingCallNotifier.callActionPendingIntent(
            context, "call-answer-1", IncomingCallNotifier.ACTION_ANSWER, null,
        )
        val shadow = shadowOf(pi)
        assertTrue(
            "Answer action must be a broadcast (reaches the core headlessly), not an activity launcher",
            shadow.isBroadcastIntent,
        )
        val saved = shadow.savedIntent
        assertEquals(
            CallActionReceiver::class.java.name,
            saved.component?.className,
        )
        assertEquals("call-answer-1", saved.getStringExtra(IncomingCallNotifier.EXTRA_CALL_ID))
        assertEquals(
            IncomingCallNotifier.ACTION_ANSWER,
            saved.getStringExtra(IncomingCallNotifier.EXTRA_CALL_ACTION),
        )
    }

    @Test
    @Config(sdk = [34])
    fun callAction_decline_isBroadcastToCallActionReceiver() {
        val pi = IncomingCallNotifier.callActionPendingIntent(
            context, "call-decline-1", IncomingCallNotifier.ACTION_DECLINE, null,
        )
        val shadow = shadowOf(pi)
        assertTrue(
            "Decline action must be a broadcast, not an activity launcher",
            shadow.isBroadcastIntent,
        )
        val saved = shadow.savedIntent
        assertEquals(CallActionReceiver::class.java.name, saved.component?.className)
        assertEquals("call-decline-1", saved.getStringExtra(IncomingCallNotifier.EXTRA_CALL_ID))
        assertEquals(
            IncomingCallNotifier.ACTION_DECLINE,
            saved.getStringExtra(IncomingCallNotifier.EXTRA_CALL_ACTION),
        )
    }

    // ── BGS-07 (Step 10): headless full forwarder — message notif + ring cancel + timeout ──
    //
    // The prior test asserted the headless upcall as *ring-only* (a ring posts a
    // ring). That masked the BGS-07 gap: the webview-absent forwarder must ALSO
    // post a native message notification and CANCEL a stale/ended CallStyle ring
    // (an abandoned incoming call must not leave the full-screen notification up
    // forever). Rewritten to drive all three headless upcall targets.

    @Test
    @Config(sdk = [34])
    fun bgs07_headless_message_notif_and_ring_cancel() {
        val nm = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager

        // (1) MESSAGE: the webview-absent forwarder posts a native message
        //     notification — a message arriving headlessly must not be silently
        //     stored with no notification (BGS-07). The Rust→Kotlin `showMessage`
        //     upcall resolves the process Application context and posts via
        //     ActionableMessageNotifier to the dedicated message channel.
        HeadlessBridge.showMessage("chat-7", "msg-7", "Alice")
        val msgId = ActionableMessageNotifier.notificationIdFor("chat-7")
        val msgNotif = shadowOf(nm).getNotification(ActionableMessageNotifier.chatTagFor("chat-7"), msgId)
        assertNotNull("Headless message upcall must post a message notification", msgNotif)
        assertEquals(
            "Headless message posts to the dedicated message channel",
            ActionableMessageNotifier.MESSAGE_CHANNEL_ID,
            msgNotif!!.channelId,
        )

        // (2) RING CANCEL on CallEnded: the forwarder cancels the CallStyle ring
        //     for an ended/abandoned call (dropped entirely by the ring-only
        //     forwarder — the exact gap BGS-07 closes).
        HeadlessBridge.showIncomingCall("call-7", "Bob", false)
        val ringId = IncomingCallNotifier.notificationIdFor("call-7")
        assertNotNull("Ring posted before cancel", shadowOf(nm).getNotification(ringId))

        HeadlessBridge.cancelIncomingCall("call-7")
        assertNull(
            "CallEnded must cancel the headless CallStyle ring",
            shadowOf(nm).getNotification(ringId),
        )
    }

    @Test
    @Config(sdk = [34])
    fun bgs07_ring_timeout_auto_cancels() {
        // Deterministic ring timeout (BGS-07): an abandoned incoming call whose
        // caller gives up before the forwarder sees a CallEnded must not ring
        // forever. Inject a manual scheduler that captures the scheduled action
        // instead of a wall-clock Handler, so the timeout fires deterministically
        // with NO wall-clock sleep.
        var captured: (() -> Unit)? = null
        var capturedDelay = -1L
        val original = IncomingCallNotifier.timeoutScheduler
        IncomingCallNotifier.timeoutScheduler =
            IncomingCallNotifier.TimeoutScheduler { delayMs, action ->
                capturedDelay = delayMs
                captured = action
                ({ captured = null })
            }
        try {
            IncomingCallNotifier.showIncomingCall(
                context = context,
                callId = "call-timeout",
                callerName = "Carol",
                isVideo = false,
                smallIcon = android.R.drawable.stat_notify_sync,
                launchIntent = null,
                useFullScreenIntent = true,
            )
            val nm = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
            val id = IncomingCallNotifier.notificationIdFor("call-timeout")
            assertNotNull("Ring posted", shadowOf(nm).getNotification(id))
            assertNotNull("showIncomingCall must arm a ring timeout", captured)
            assertEquals(
                "Ring timeout must be armed for the product ring duration",
                IncomingCallNotifier.RING_TIMEOUT_MS,
                capturedDelay,
            )

            // Fire the timeout deterministically (no wall-clock sleep).
            captured!!.invoke()
            assertNull(
                "Ring timeout must auto-cancel the abandoned CallStyle notification",
                shadowOf(nm).getNotification(id),
            )
        } finally {
            IncomingCallNotifier.timeoutScheduler = original
        }
    }

    @Test
    @Config(sdk = [34])
    fun cancel_removesPostedNotification() {
        IncomingCallNotifier.showIncomingCall(
            context = context,
            callId = "call-3",
            callerName = "Carol",
            isVideo = false,
            smallIcon = android.R.drawable.stat_notify_sync,
            launchIntent = null,
            useFullScreenIntent = true,
        )
        val id = IncomingCallNotifier.notificationIdFor("call-3")
        val nm = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
        assertNotNull(shadowOf(nm).getNotification(id))

        IncomingCallNotifier.cancel(context, "call-3")
        assertNull("Cancel removes the notification", shadowOf(nm).getNotification(id))
    }
}