I want an Android timer app to be able to run in the background


I coded a timer app that outputs text-to-speech audio, but the timer stops every time I switch to another app or lock the phone, and I would like it to continue timing even when I do that.

Here are the 2 composables that make up the current app:

**@Composable**
    fun rememberTextToSpeech(context: Context): TextToSpeech {
        var ttsInstance: TextToSpeech? = null

        val tts = remember {
            TextToSpeech(context) { status ->
                if (status == TextToSpeech.SUCCESS) {
                    ttsInstance?.language = Locale("spa")
                }
            }.also {
                ttsInstance = it
            }
        }

        DisposableEffect(Unit) {
            onDispose {
                tts.shutdown()
            }
        }

        return tts
    }

    @OptIn(ExperimentalMaterial3Api::class)
    **@Composable**
    fun TimerScreen() {
        val context = LocalContext.current
        val tts = rememberTextToSpeech(context)

        var inputText      by remember { mutableStateOf("") }
        var timeLeft       by remember { mutableStateOf(0) }     // seconds
        var isRunning      by remember { mutableStateOf(false) }
        var isFinished     by remember { mutableStateOf(false) } // ‹— NEW
        var initialMinutes by remember { mutableStateOf(0) }

        /* ─────────────  COUNT‑DOWN  ───────────── */
        LaunchedEffect(isRunning, timeLeft) {
            if (isRunning && timeLeft > 0) {
                delay(1000)          // tick
                timeLeft -= 1
            } else if (isRunning && timeLeft == 0) {
                isRunning  = false
                isFinished = true      // trigger the repeater
            }
        }

        /* ─────────────  REPEATER  ───────────── */
        LaunchedEffect(isFinished) {
            while (isFinished) {       // keeps looping until Reset toggles it off
                val msg = "Your $initialMinutes minute timer is done"
                tts.speak(msg, TextToSpeech.QUEUE_FLUSH, null, "timer_done")
                delay(3000)           // repeat interval (3s feels natural)
            }
            tts.stop()                 // stop any ongoing speech when reset
        }

        /* ─────────────  UI  ───────────── */
        Column(
            modifier = Modifier
                .fillMaxSize()
                .background(Color(0xFFF5F5DC))
                .padding(24.dp),
            horizontalAlignment = Alignment.CenterHorizontally,
            verticalArrangement = Arrangement.Center
        ) {

            Text(
                "How many minutes would you like to time?",
                fontSize = 20.sp,
                fontWeight = FontWeight.Bold,
                color = Color.Black
            )

            Spacer(Modifier.height(32.dp))

            TextField(
                value = inputText,
                onValueChange = { if (it.all(Char::isDigit)) inputText = it },
                label = { Text("Minutes") },
                singleLine = true,
                modifier = Modifier.width(120.dp)
            )

            Spacer(Modifier.height(64.dp))

            val mm = timeLeft / 60
            val ss = timeLeft % 60

            Column (
                horizontalAlignment = Alignment.CenterHorizontally,
                verticalArrangement = Arrangement.Center
            )   {

                Text(
                    String.format("%02d:%02d", mm, ss),
                    fontSize = 64.sp,
                    fontWeight = FontWeight.Bold
                )

                Spacer(Modifier.height(64.dp))

                /* START */
                Button(
                    onClick = {
                        val minutes = inputText.toIntOrNull() ?: 0
                        if (minutes > 0) {
                            initialMinutes = minutes
                            timeLeft = minutes * 60
                            isRunning = true
                            isFinished = false   // safeguard
                        }
                    },
                    enabled = !isRunning && inputText.isNotBlank(),
                    colors = ButtonDefaults.buttonColors(containerColor = Color.Green)
                ) { Text("Start", color = Color.Black) }

                /* RESET */
                Button(
                    onClick = {
                        isRunning = false
                        isFinished = false      // stops the repeater loop
                        timeLeft = 0
                        inputText = ""
                    },
                    colors = ButtonDefaults.buttonColors(containerColor = Color.Red)
                ) { Text("Reset") }
            }
        }
    }

I've only tried the original implementation, which I did not expect to stop when switching the app or locking the phone.

1
Jul 14 at 2:01 AM
User AvatarNicolas
#android#kotlin

Accepted Answer

Your current timer relies on LaunchedEffect, which runs within the Composable lifecycle.
When the app is minimized or the screen is locked, the Composable is no longer active, can cause the timer to pause.

Use a Foreground Service (depends on the OS version or custom ROM, please follow the best practices : https://developer.android.com/develop/background-work/services/fgs)

I think a foreground service is ideal for this use case because:

  • It keeps the timer running in the background.

  • It can trigger TTS when the timer finishes.

  • It displays a persistent notification to inform the user that the app is running (required for Android's foreground service policy).

  • It works even when the screen is locked or the app is in the background.

    Hope this helps....

User AvatarBashu
Jul 14 at 3:23 AM
2