tauri-plugin-background-service 0.6.0

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.content.SharedPreferences
import android.os.Build
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import org.junit.After
import org.junit.Assert.*
import org.junit.Before
import org.junit.Ignore
import org.junit.Test
import org.junit.runner.RunWith

/**
 * Instrumented tests for service behaviour when POST_NOTIFICATIONS permission is denied.
 *
 * On Android 13+ (API 33), apps require POST_NOTIFICATIONS to post user-facing
 * notifications. Foreground service notifications are exempt from this restriction,
 * so the service should start and function correctly regardless of the permission state.
 *
 * On API < 33, POST_NOTIFICATIONS does not exist — these tests still pass because
 * the permission grant/revoke is skipped.
 *
 * @Ignore'd because Waydroid kills the instrumentation process when pm revoke is
 * executed via uiAutomation.executeShellCommand. Passes on real devices/emulators.
 */
@Ignore("Waydroid kills instrumentation on pm revoke — passes on real devices/emulators")
@RunWith(AndroidJUnit4::class)
class PermissionDenialTest {

    private lateinit var context: Context
    private lateinit var prefs: SharedPreferences

    @Before
    fun setup() {
        context = InstrumentationRegistry.getInstrumentation().targetContext
        prefs = context.getSharedPreferences("bg_service", Context.MODE_PRIVATE)

        // Revoke POST_NOTIFICATIONS on API 33+
        revokeNotificationPermission()

        // Reset state
        LifecycleService.isRunning = false
        LifecycleService.autoRestarting = false
        prefs.edit().clear().apply()
    }

    @After
    fun tearDown() {
        // Restore permission on API 33+
        grantNotificationPermission()

        // Stop service
        try {
            context.startService(
                Intent(context, LifecycleService::class.java).apply {
                    action = LifecycleService.ACTION_STOP
                }
            )
        } catch (_: Exception) { /* service may not be running */ }

        LifecycleService.isRunning = false
        LifecycleService.autoRestarting = false
        prefs.edit().clear().apply()
    }

    private fun revokeNotificationPermission() {
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
            InstrumentationRegistry.getInstrumentation().uiAutomation
                .executeShellCommand(
                    "pm revoke ${context.packageName} android.permission.POST_NOTIFICATIONS"
                )
            Thread.sleep(500)
        }
    }

    private fun grantNotificationPermission() {
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
            InstrumentationRegistry.getInstrumentation().uiAutomation
                .executeShellCommand(
                    "pm grant ${context.packageName} android.permission.POST_NOTIFICATIONS"
                )
            Thread.sleep(500)
        }
    }

    private fun startForegroundService(label: String, type: String = "dataSync") {
        val intent = Intent(context, LifecycleService::class.java).apply {
            action = LifecycleService.ACTION_START
            putExtra(LifecycleService.EXTRA_LABEL, label)
            putExtra(LifecycleService.EXTRA_SERVICE_TYPE, type)
        }
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
            context.startForegroundService(intent)
        } else {
            context.startService(intent)
        }
        Thread.sleep(1000)
    }

    private fun stopService() {
        context.startService(
            Intent(context, LifecycleService::class.java).apply {
                action = LifecycleService.ACTION_STOP
            }
        )
        Thread.sleep(1000)
    }

    // ── Service starts without notification permission ─────────────────

    @Test
    fun serviceStartsWithoutNotificationPermission() {
        startForegroundService("No Permission Test")

        // Service should still start — foreground service notifications are
        // exempt from the POST_NOTIFICATIONS requirement
        assertTrue(
            "Service should start without POST_NOTIFICATIONS",
            LifecycleService.isRunning
        )

        // Channel should still be created
        val nm = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
        val channel = nm.getNotificationChannel(LifecycleService.CHANNEL_ID)
        assertNotNull("Notification channel should be created", channel)
    }

    // ── SharedPreferences persist without notification permission ──────

    @Test
    fun sharedPreferencesPersistedWithoutNotificationPermission() {
        startForegroundService("Persist Test", "specialUse")

        assertEquals(
            "Persist Test",
            prefs.getString("bg_service_label", null)
        )
        assertEquals(
            "specialUse",
            prefs.getString("bg_service_type", null)
        )
    }

    // ── Service stops correctly without notification permission ────────

    @Test
    fun serviceStopWorksWithoutNotificationPermission() {
        startForegroundService("Stop Test")
        assertTrue("Service should be running", LifecycleService.isRunning)

        stopService()

        assertFalse(
            "Service should stop without POST_NOTIFICATIONS",
            LifecycleService.isRunning
        )
        assertNull(
            "Prefs should be cleared after stop",
            prefs.getString("bg_service_label", null)
        )
    }

    // ── Foreground notification visible despite denied permission ──────

    @Test
    fun foregroundNotificationVisibleDespiteDeniedPermission() {
        // Foreground service notifications are exempt from POST_NOTIFICATIONS
        startForegroundService("Exempt Notification")

        val nm = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
        val notifications = nm.activeNotifications
        val found = notifications.any { it.id == LifecycleService.NOTIF_ID }
        assertTrue(
            "Foreground notification should be visible despite denied POST_NOTIFICATIONS",
            found
        )
    }
}