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 android.content.Intent
import android.os.Bundle
import android.service.notification.StatusBarNotification
import androidx.core.app.NotificationCompat
import androidx.core.app.RemoteInput
import androidx.test.core.app.ApplicationProvider
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotSame
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
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

@RunWith(RobolectricTestRunner::class)
class ActionableMessageNotifierTest {
    private lateinit var context: Context

    @Before
    fun setup() {
        context = ApplicationProvider.getApplicationContext()
        // Run MessageNotificationActionReceiver's dispatch INLINE so its
        // direct-invoke tests (receiver_markRead / receiver_reply) observe the
        // post-dispatch state deterministically. The default spawns a real worker
        // (fire-and-forget) which would race the assertions; the off-main test
        // lives in Bgs20OffMainThreadTest and installs its own
        // thread-distinguishing executor (BGS-20, Step 11).
        MessageNotificationActionReceiver.actionExecutor = { _, task -> task() }
    }

    @After
    fun tearDown() {
        MessageNotificationActionReceiver.actionExecutor =
            MessageNotificationActionReceiver.DEFAULT_ACTION_EXECUTOR
        MessageNotificationActionDispatch.dispatcher = RealMessageNotificationActionDispatcher()
    }

    @Test
    @Config(sdk = [34])
    fun showMessageNotification_postsActionableMessageNotification() {
        ActionableMessageNotifier.showMessageNotification(
            context = context,
            notificationId = 44001,
            chatId = "chat-1",
            messageId = "msg-1",
            title = "Alice",
            body = "hello",
            routeUri = "bg-service://chat?chat_id=chat-1&message_id=msg-1",
            smallIcon = android.R.drawable.sym_def_app_icon,
            launchIntent = Intent(Intent.ACTION_MAIN).setPackage(context.packageName),
        )

        val nm = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
        val chatTag = "chat:chat-1"

        // NTF-13 (Step 9b): posted under the per-chat TAG (chatTag, id), not id-only.
        val messageSbn = sbnFor(nm, chatTag, 44001)
        assertNotNull(messageSbn)
        val notification = messageSbn!!.notification

        assertEquals(android.app.Notification.CATEGORY_MESSAGE, notification.category)
        assertEquals(2, notification.actions?.size ?: 0)
        // setGroup(chatTag)
        assertEquals(chatTag, notification.group)
        // MessagingStyle (latest-message-only)
        assertNotNull(
            NotificationCompat.MessagingStyle.extractMessagingStyleFromNotification(notification),
        )

        val contentIntent = shadowOf(notification.contentIntent).savedIntent
        assertEquals(Intent.ACTION_VIEW, contentIntent.action)
        assertEquals("bg-service://chat?chat_id=chat-1&message_id=msg-1", contentIntent.data.toString())
        assertTrue(contentIntent.categories?.contains(Intent.CATEGORY_BROWSABLE) == true)
        assertEquals(
            "bg-service://chat?chat_id=chat-1&message_id=msg-1",
            contentIntent.getStringExtra(ActionableMessageNotifier.EXTRA_ROUTE_URI),
        )

        val replyAction = notification.actions[0]
        val markReadAction = notification.actions[1]
        assertEquals("Reply", replyAction.title.toString())
        assertEquals("Mark as read", markReadAction.title.toString())
        assertTrue(shadowOf(replyAction.actionIntent).isBroadcastIntent)
        assertTrue(shadowOf(markReadAction.actionIntent).isBroadcastIntent)
        assertEquals(
            MessageNotificationActionReceiver::class.java.name,
            shadowOf(replyAction.actionIntent).savedIntent.component?.className,
        )
        assertEquals(
            MessageNotificationActionReceiver::class.java.name,
            shadowOf(markReadAction.actionIntent).savedIntent.component?.className,
        )

        // Per-chat group SUMMARY under (chatTag, SUMMARY_NOTIFICATION_ID), distinct from the message.
        val summarySbn = sbnFor(nm, chatTag, ActionableMessageNotifier.SUMMARY_NOTIFICATION_ID)
        assertNotNull(summarySbn)
        assertNotSame(notification, summarySbn!!.notification)
        assertTrue(NotificationCompat.isGroupSummary(summarySbn.notification))
    }

    @Test
    @Config(sdk = [34])
    fun showMessageNotification_twoSameChatPostsUpdateNotStack() {
        val nm = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
        val chatTag = "chat:chat-1"

        // Two messages in the same chat share the chat-stable id (Step 9a) and tag,
        // so the second post must REPLACE the first (update), not stack a second one.
        ActionableMessageNotifier.showMessageNotification(
            context = context,
            notificationId = 44001,
            chatId = "chat-1",
            messageId = "msg-1",
            title = "Alice",
            body = "first",
            routeUri = "bg-service://chat?chat_id=chat-1&message_id=msg-1",
            smallIcon = android.R.drawable.sym_def_app_icon,
            launchIntent = null,
        )
        ActionableMessageNotifier.showMessageNotification(
            context = context,
            notificationId = 44001,
            chatId = "chat-1",
            messageId = "msg-2",
            title = "Alice",
            body = "second",
            routeUri = "bg-service://chat?chat_id=chat-1&message_id=msg-2",
            smallIcon = android.R.drawable.sym_def_app_icon,
            launchIntent = null,
        )

        // Exactly ONE message notification under (chatTag, 44001) — replaced, not stacked.
        val messages = nm.activeNotifications.filter { it.tag == chatTag && it.id == 44001 }
        assertEquals(1, messages.size)
        // Latest body wins (MessagingStyle, latest-message-only).
        val style = NotificationCompat.MessagingStyle.extractMessagingStyleFromNotification(
            messages.single().notification,
        )
        assertNotNull(style)
        assertEquals("second", style!!.messages.last().text.toString())
        // Message + summary => exactly two active notifications for the chat.
        assertEquals(2, nm.activeNotifications.count { it.tag == chatTag })
    }

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

        ActionableMessageNotifier.showMessageNotification(
            context = context,
            notificationId = 44001,
            chatId = "chat-1",
            messageId = "msg-1",
            title = "Alice",
            body = "hi",
            routeUri = "bg-service://chat?chat_id=chat-1&message_id=msg-1",
            smallIcon = android.R.drawable.sym_def_app_icon,
            launchIntent = null,
        )
        ActionableMessageNotifier.showMessageNotification(
            context = context,
            notificationId = 44002,
            chatId = "chat-2",
            messageId = "msg-9",
            title = "Bob",
            body = "yo",
            routeUri = "bg-service://chat?chat_id=chat-2&message_id=msg-9",
            smallIcon = android.R.drawable.sym_def_app_icon,
            launchIntent = null,
        )

        val tags = nm.activeNotifications.map { it.tag }.toSet()
        assertTrue(tags.contains("chat:chat-1"))
        assertTrue(tags.contains("chat:chat-2"))
        // Each chat's message + summary share that chat's distinct tag.
        assertEquals(2, nm.activeNotifications.count { it.tag == "chat:chat-1" })
        assertEquals(2, nm.activeNotifications.count { it.tag == "chat:chat-2" })
    }

    @Test
    @Config(sdk = [34])
    fun cancel_dismissesTagKeyedMessageNotification() {
        val nm = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
        val chatTag = "chat:chat-1"

        ActionableMessageNotifier.showMessageNotification(
            context = context,
            notificationId = 44001,
            chatId = "chat-1",
            messageId = "msg-1",
            title = "Alice",
            body = "hello",
            routeUri = "bg-service://chat?chat_id=chat-1&message_id=msg-1",
            smallIcon = android.R.drawable.sym_def_app_icon,
            launchIntent = null,
        )
        assertNotNull(sbnFor(nm, chatTag, 44001))

        // NTF-13 (Step 9b repair): cancel is TAG-AWARE — it dismisses the message posted
        // under (chatTag, id). The pre-9b id-only nm.cancel(id) was a silent no-op on a
        // tag-keyed post (NotificationManager.cancel(int) delegates to cancel(null, id),
        // which is tag-sensitive); this overload cancels (chatTag, id) so a successful
        // reply/mark-read dismisses the message again. The per-chat SUMMARY
        // (chatTag, SUMMARY_NOTIFICATION_ID) is NOT canceled here: in production AOSP
        // auto-removes a setGroupSummary(true) summary once its last child is canceled, so
        // dismissing the message clears both; explicit cancel-all-by-tag (robust across
        // every Android version) is Step 9c's cancelChat scope.
        ActionableMessageNotifier.cancel(context, "chat-1", 44001)

        assertNull(sbnFor(nm, chatTag, 44001))
    }

    @Test
    @Config(sdk = [34])
    fun handleNotificationActionResult_cancelClearsWholeChatByTag_notJustTappedId() {
        val nm = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
        val chatTagA = "chat:chat-A"

        // chat-A: showMessageNotification posts BOTH the message (chat:chat-A, 44001) AND
        // the per-chat summary (chat:chat-A, SUMMARY_NOTIFICATION_ID=0).
        ActionableMessageNotifier.showMessageNotification(
            context = context,
            notificationId = 44001,
            chatId = "chat-A",
            messageId = "m",
            title = "Alice",
            body = "hello",
            routeUri = "bg-service://chat?chat_id=chat-A&message_id=m",
            smallIcon = android.R.drawable.sym_def_app_icon,
            launchIntent = null,
        )
        // chat-B: proves the clear is tag-SCOPED, not cancelAll.
        ActionableMessageNotifier.showMessageNotification(
            context = context,
            notificationId = 44002,
            chatId = "chat-B",
            messageId = "m",
            title = "Bob",
            body = "yo",
            routeUri = "bg-service://chat?chat_id=chat-B&message_id=m",
            smallIcon = android.R.drawable.sym_def_app_icon,
            launchIntent = null,
        )
        // Precondition: chat-A carries its message + summary (two notifications).
        assertEquals(2, nm.activeNotifications.count { it.tag == chatTagA })

        // NTF-13 (Step 9c): the Cancel branch clears the WHOLE chat by tag — message AND
        // summary — not just the tapped id. mark_messages_read marks the whole chat
        // (headless_core.rs:309); the Cancel outcome is shared by reply-success,
        // mark-read-success, and permanent-failure. Driving through the BRANCH (not
        // cancelChat directly) is load-bearing: reverting the branch to the 9b single-id
        // cancel leaves the summary (chatTag, 0) alive — Robolectric's flat store does NOT
        // emulate AOSP group-summary auto-removal — so none{tag==chatTagA} would RED,
        // pinning the summary dismissal (the sole load-bearing differentiator).
        handleNotificationActionResult(
            context,
            NotificationActionOutcome.Cancel,
            "chat-A",
            "m",
            44001,
        )

        // ZERO remain for chat-A — the message AND the summary are dismissed.
        assertTrue(
            "Cancel must clear the chat's message AND summary (tag-scoped, not just the tapped id)",
            nm.activeNotifications.none { it.tag == chatTagA },
        )
        // Cross-chat isolation: chat-B survives (proves tag-scoped, NOT cancelAll).
        assertTrue(
            "Cancel must NOT clear other chats (tag-scoped, not cancelAll)",
            nm.activeNotifications.any { it.tag == "chat:chat-B" },
        )
    }

    /** Tag+id keyed retrieval mirroring the production `nm.notify(tag, id, n)`. */
    private fun sbnFor(nm: NotificationManager, tag: String, id: Int): StatusBarNotification? =
        nm.activeNotifications.firstOrNull { it.tag == tag && it.id == id }

    @Test
    fun receiver_markReadDispatchesToCoreAction() {
        val fake = FakeMessageNotificationActionDispatcher()
        MessageNotificationActionDispatch.dispatcher = fake

        MessageNotificationActionReceiver().onReceive(
            context,
            Intent(MessageNotificationActionReceiver.ACTION_MESSAGE_NOTIFICATION).apply {
                putExtra(ActionableMessageNotifier.EXTRA_NOTIFICATION_ID, 77)
                putExtra(ActionableMessageNotifier.EXTRA_CHAT_ID, "chat-1")
                putExtra(ActionableMessageNotifier.EXTRA_MESSAGE_ID, "msg-1")
                putExtra(
                    ActionableMessageNotifier.EXTRA_ACTION,
                    ActionableMessageNotifier.ACTION_MARK_READ,
                )
            },
        )

        assertEquals(
            listOf(FakeMessageNotificationActionDispatcher.MarkReadCall("chat-1", "msg-1", 77)),
            fake.markedRead,
        )
        assertTrue(fake.replies.isEmpty())
    }

    @Test
    fun receiver_replyDispatchesTrimmedRemoteInput() {
        val fake = FakeMessageNotificationActionDispatcher()
        MessageNotificationActionDispatch.dispatcher = fake
        val intent = Intent(MessageNotificationActionReceiver.ACTION_MESSAGE_NOTIFICATION).apply {
            putExtra(ActionableMessageNotifier.EXTRA_NOTIFICATION_ID, 78)
            putExtra(ActionableMessageNotifier.EXTRA_CHAT_ID, "chat-2")
            putExtra(ActionableMessageNotifier.EXTRA_MESSAGE_ID, "msg-2")
            putExtra(ActionableMessageNotifier.EXTRA_ACTION, ActionableMessageNotifier.ACTION_REPLY)
        }
        val remoteInput = RemoteInput.Builder(ActionableMessageNotifier.REMOTE_INPUT_KEY).build()
        RemoteInput.addResultsToIntent(
            arrayOf(remoteInput),
            intent,
            Bundle().apply {
                putCharSequence(ActionableMessageNotifier.REMOTE_INPUT_KEY, "  hello back  ")
            },
        )

        MessageNotificationActionReceiver().onReceive(context, intent)

        assertEquals(
            listOf(
                FakeMessageNotificationActionDispatcher.ReplyCall(
                    "chat-2",
                    "msg-2",
                    78,
                    "hello back",
                ),
            ),
            fake.replies,
        )
        assertTrue(fake.markedRead.isEmpty())
    }
}