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.Test
import org.junit.runner.RunWith

/**
 * Instrumented tests for [LifecycleService], running on a real device/emulator.
 *
 * Tests foreground notification behaviour, notification channel creation,
 * START_STICKY restart semantics, and state cleanup on destroy.
 */
@RunWith(AndroidJUnit4::class)
class LifecycleServiceInstrumentedTest {

    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)
        // Reset static state
        LifecycleService.isRunning = false
        LifecycleService.autoRestarting = false
        prefs.edit().clear().apply()
    }

    @After
    fun tearDown() {
        // Stop the service if running
        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 waitUntil(
        timeoutMs: Long = 5_000L,
        intervalMs: Long = 100L,
        condition: () -> Boolean
    ) {
        val deadline = System.currentTimeMillis() + timeoutMs
        while (!condition()) {
            if (System.currentTimeMillis() > deadline) {
                throw AssertionError("Condition not met within ${timeoutMs}ms")
            }
            Thread.sleep(intervalMs)
        }
    }

    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)
        }
        waitUntil { LifecycleService.isRunning }
    }

    private fun stopService() {
        context.startService(
            Intent(context, LifecycleService::class.java).apply {
                action = LifecycleService.ACTION_STOP
            }
        )
        waitUntil(timeoutMs = 3_000L) { !LifecycleService.isRunning }
    }

    // ── Foreground notification appears ────────────────────────────────

    @Test
    fun foregroundNotificationAppearsAfterStart() {
        startForegroundService("Instrumented Test")

        assertTrue("Service should be running", LifecycleService.isRunning)

        if (!isWaydroid()) {
            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 with id ${LifecycleService.NOTIF_ID} should be active",
                found
            )
        }
    }

    // ── Notification channel created ───────────────────────────────────

    @Test
    fun notificationChannelCreatedCorrectly() {
        startForegroundService("Channel Test")

        val nm = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
        val channel = nm.getNotificationChannel(LifecycleService.CHANNEL_ID)

        assertNotNull("Notification channel should exist", channel)
        channel?.let {
            assertEquals("bg_keepalive", it.id)
            assertEquals(NotificationManager.IMPORTANCE_LOW, it.importance)
            assertEquals("Service Status", it.name.toString())
            assertFalse("Badge should be disabled", it.canShowBadge())
        }
    }

    // ── START_STICKY restart behaviour ─────────────────────────────────

    @Test
    fun serviceIsRunningWithStartStickySemantics() {
        startForegroundService("Sticky Test")

        // Service should be running and prefs should persist for OS restart detection
        assertTrue("Service should be running", LifecycleService.isRunning)
        assertEquals(
            "Service label should be persisted for restart detection",
            "Sticky Test",
            prefs.getString("bg_service_label", null)
        )
        assertEquals(
            "dataSync",
            prefs.getString("bg_service_type", null)
        )
    }

    @Test
    fun osRestartDetectsPersistedConfig() {
        // Simulate a previously running service by persisting config
        prefs.edit()
            .putString("bg_service_label", "Previous Run")
            .putString("bg_service_type", "specialUse")
            .apply()

        // Start service with null intent triggers handleOsRestart path
        // We simulate by starting with a null action intent
        val intent = Intent(context, LifecycleService::class.java)
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
            context.startForegroundService(intent)
        } else {
            context.startService(intent)
        }
        Thread.sleep(1000)

        // The OS restart path should set auto-start config
        assertTrue(
            "Auto-start pending flag should be set",
            prefs.getBoolean("bg_auto_start_pending", false)
        )
        assertEquals(
            "Previous Run",
            prefs.getString("bg_auto_start_label", null)
        )
        assertEquals(
            "specialUse",
            prefs.getString("bg_auto_start_type", null)
        )

        // Cleanup
        stopService()
    }

    // ── onDestroy resets state ─────────────────────────────────────────

    @Test
    fun onDestroy_resetsIsRunningAndAutoRestarting() {
        startForegroundService("Destroy Test")
        assertTrue("Should be running before stop", LifecycleService.isRunning)

        stopService()

        assertFalse(
            "isRunning should be false after onDestroy",
            LifecycleService.isRunning
        )
        assertFalse(
            "autoRestarting should be false after onDestroy",
            LifecycleService.autoRestarting
        )
    }

    // ── stop clears SharedPreferences ──────────────────────────────────

    @Test
    fun stopClearsSharedPreferences() {
        startForegroundService("Clear Test", "specialUse")
        assertNotNull("Label should exist before stop", prefs.getString("bg_service_label", null))

        stopService()

        assertNull("Label should be cleared", prefs.getString("bg_service_label", null))
        assertNull("Type should be cleared", prefs.getString("bg_service_type", null))
        assertFalse(
            "Auto-start pending should be cleared",
            prefs.getBoolean("bg_auto_start_pending", false)
        )
        assertNull(
            "Auto-start label should be cleared",
            prefs.getString("bg_auto_start_label", null)
        )
        assertNull(
            "Auto-start type should be cleared",
            prefs.getString("bg_auto_start_type", null)
        )
    }

    // ── Custom label reflected in notification ─────────────────────────

    @Test
    fun customLabelUsedInNotification() {
        startForegroundService("Custom Label Here")

        assertTrue("Service should be running", LifecycleService.isRunning)

        if (!isWaydroid()) {
            val nm = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
            val notifications = nm.activeNotifications.filter { it.id == LifecycleService.NOTIF_ID }
            assertTrue("Notification should be active", notifications.isNotEmpty())

            val extras = notifications.first().notification.extras
            val text = extras.getCharSequence(android.app.Notification.EXTRA_TEXT)?.toString()
            assertEquals("Custom Label Here", text)
        }
    }

    private fun isWaydroid(): Boolean =
        android.os.Build.FINGERPRINT.contains("waydroid", ignoreCase = true)
}