diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index f5316a8..ca2c8e0 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -28,6 +28,12 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/kotlin/dev/xitee/sleeptimer/tile/SleepTimerTileService.kt b/app/src/main/kotlin/dev/xitee/sleeptimer/tile/SleepTimerTileService.kt
new file mode 100644
index 0000000..af6455e
--- /dev/null
+++ b/app/src/main/kotlin/dev/xitee/sleeptimer/tile/SleepTimerTileService.kt
@@ -0,0 +1,135 @@
+package dev.xitee.sleeptimer.tile
+
+import android.os.Build
+import android.service.quicksettings.Tile
+import android.service.quicksettings.TileService
+import dagger.hilt.android.AndroidEntryPoint
+import dev.xitee.sleeptimer.R
+import dev.xitee.sleeptimer.core.data.model.TimerPhase
+import dev.xitee.sleeptimer.core.data.repository.SettingsRepository
+import dev.xitee.sleeptimer.core.data.repository.TimerRepository
+import dev.xitee.sleeptimer.core.data.util.remainingMillisToDisplayMinutes
+import dev.xitee.sleeptimer.core.service.SleepTimerService
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.cancel
+import kotlinx.coroutines.flow.combine
+import kotlinx.coroutines.flow.distinctUntilChanged
+import kotlinx.coroutines.launch
+import javax.inject.Inject
+
+/**
+ * Quick Settings tile that starts the sleep timer with the preset duration —
+ * `UserSettings.presetMinutes`, the value committed on the dial while idle —
+ * without opening the app. While a timer is running the tile shows the remaining
+ * minutes and a tap cancels it, mirroring the notification's Cancel action.
+ */
+@AndroidEntryPoint
+class SleepTimerTileService : TileService() {
+
+ @Inject lateinit var timerRepository: TimerRepository
+ @Inject lateinit var settingsRepository: SettingsRepository
+
+ private val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
+ private var listeningJob: Job? = null
+
+ // Last preset delivered by the collector. onClick starts the service
+ // synchronously from this cache: suspending on a DataStore read in the click
+ // path would race both the tile's own destruction (SystemUI unbinds right
+ // after the panel collapses, cancelling serviceScope) and the tap-granted
+ // FGS start exemption. Main-thread only, like all TileService callbacks.
+ private var cachedPresetMinutes: Int? = null
+
+ // True from dispatching ACTION_START until the service publishes a non-IDLE
+ // phase. A rapid second tap in that window would still read IDLE and either
+ // double-start or instantly cancel the timer; swallow it instead.
+ private var startInFlight = false
+
+ override fun onStartListening() {
+ super.onStartListening()
+ startInFlight = false
+ // The tile binds into the app process, so this observes the same in-process
+ // StateFlow the foreground service writes on every tick.
+ listeningJob?.cancel()
+ listeningJob = serviceScope.launch {
+ combine(timerRepository.timerState, settingsRepository.settings) { timerState, settings ->
+ cachedPresetMinutes = settings.presetMinutes
+ TileModel(
+ phase = timerState.phase,
+ minutes = when (timerState.phase) {
+ TimerPhase.IDLE -> settings.presetMinutes
+ else -> remainingMillisToDisplayMinutes(timerState.remainingMillis)
+ },
+ )
+ }
+ // remainingMillis changes every second but the displayed minutes only
+ // change once a minute — skip the redundant updateTile() calls.
+ .distinctUntilChanged()
+ .collect(::render)
+ }
+ }
+
+ override fun onStopListening() {
+ listeningJob?.cancel()
+ listeningJob = null
+ super.onStopListening()
+ }
+
+ override fun onDestroy() {
+ serviceScope.cancel()
+ super.onDestroy()
+ }
+
+ override fun onClick() {
+ super.onClick()
+ when (timerRepository.timerState.value.phase) {
+ TimerPhase.IDLE -> startTimerWithPreset()
+ // RUNNING / FADING_OUT: cancel restores volume and stops the service; if
+ // the countdown ended in the meantime, the fresh instance's stale-intent
+ // guard stops it again without touching timer state.
+ else -> SleepTimerService.cancel(this)
+ }
+ }
+
+ private fun startTimerWithPreset() {
+ if (startInFlight) return
+ // Not collected yet (a tap within the first frames of a cold panel open,
+ // before the tile has rendered a subtitle) — drop the tap rather than
+ // start a duration the user never saw.
+ val minutes = cachedPresetMinutes ?: return
+ // start() swallows the denied-start case (background-restricted app), so a
+ // refused tap is a logged no-op instead of a crash.
+ startInFlight = SleepTimerService.start(this, minutes * 60_000L)
+ }
+
+ private fun render(model: TileModel) {
+ if (model.phase != TimerPhase.IDLE) startInFlight = false
+ val tile = qsTile ?: return
+ tile.state = if (model.phase == TimerPhase.IDLE) Tile.STATE_INACTIVE else Tile.STATE_ACTIVE
+ val statusText = when (model.phase) {
+ TimerPhase.FADING_OUT -> getString(R.string.qs_tile_fading_out)
+ // coerceAtLeast: the helper rounds up, but the service publishes one
+ // final RUNNING tick with remainingMillis == 0 before the phase flips —
+ // never show "0 min".
+ else -> getString(R.string.widget_minutes, model.minutes.coerceAtLeast(1))
+ }
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
+ tile.subtitle = statusText
+ } else {
+ // No subtitle before Q — fold the status into the label while a timer
+ // is active so API 26-28 still see the remaining time, and restore the
+ // plain name when idle (the label is also the tile's name in the QS
+ // edit panel).
+ tile.label = if (model.phase == TimerPhase.IDLE) {
+ getString(R.string.app_name)
+ } else {
+ statusText
+ }
+ }
+ tile.updateTile()
+ }
+
+ private data class TileModel(val phase: TimerPhase, val minutes: Int)
+}
diff --git a/app/src/main/kotlin/dev/xitee/sleeptimer/widget/SleepTimerWidgetProvider.kt b/app/src/main/kotlin/dev/xitee/sleeptimer/widget/SleepTimerWidgetProvider.kt
index 3b7dc9b..8dc6698 100644
--- a/app/src/main/kotlin/dev/xitee/sleeptimer/widget/SleepTimerWidgetProvider.kt
+++ b/app/src/main/kotlin/dev/xitee/sleeptimer/widget/SleepTimerWidgetProvider.kt
@@ -4,7 +4,6 @@ import android.appwidget.AppWidgetManager
import android.appwidget.AppWidgetProvider
import android.content.Context
import android.content.Intent
-import android.util.Log
import dagger.hilt.android.AndroidEntryPoint
import dev.xitee.sleeptimer.core.data.model.TimerPhase
import dev.xitee.sleeptimer.core.data.model.startMinutesFor
@@ -148,21 +147,10 @@ class SleepTimerWidgetProvider : AppWidgetProvider() {
} else {
config.startMinutes(settingsRepository.settings.first().presetMinutes)
}
- val serviceIntent = Intent().apply {
- action = SleepTimerService.ACTION_START
- setClassName(context, SleepTimerService::class.java.name)
- putExtra(SleepTimerService.EXTRA_DURATION_MILLIS, startMinutes * 60_000L)
- }
- try {
- context.startForegroundService(serviceIntent)
- } catch (e: IllegalStateException) {
- // A widget tap normally grants the FGS background-start exemption, but
- // a background-restricted app (Settings → "Don't allow background
- // activity") is denied it and startForegroundService throws
- // ForegroundServiceStartNotAllowedException (an IllegalStateException).
- // Swallow it so the tap is a harmless no-op instead of crashing.
- Log.w(TAG, "Foreground service start denied for widget tap", e)
- }
+ // start() owns the FGS dispatch and swallows the denied-start case
+ // (background-restricted app), so the tap degrades to a logged
+ // no-op instead of crashing.
+ SleepTimerService.start(context, startMinutes * 60_000L)
} finally {
starting.set(false)
pending.finish()
@@ -171,7 +159,6 @@ class SleepTimerWidgetProvider : AppWidgetProvider() {
}
companion object {
- private const val TAG = "SleepTimerWidget"
const val ACTION_START_TIMER = "dev.xitee.sleeptimer.action.WIDGET_START_TIMER"
// Process-lifetime scope for the short DataStore reads/writes the receiver
diff --git a/app/src/main/kotlin/dev/xitee/sleeptimer/widget/SleepTimerWidgetRenderer.kt b/app/src/main/kotlin/dev/xitee/sleeptimer/widget/SleepTimerWidgetRenderer.kt
index db735a7..0b89fd0 100644
--- a/app/src/main/kotlin/dev/xitee/sleeptimer/widget/SleepTimerWidgetRenderer.kt
+++ b/app/src/main/kotlin/dev/xitee/sleeptimer/widget/SleepTimerWidgetRenderer.kt
@@ -99,14 +99,10 @@ internal object SleepTimerWidgetRenderer {
private fun cancelPendingIntent(context: Context): PendingIntent {
// A stale tap after the timer already ended is harmless: the service's
// no-active-countdown guard stops itself before startForeground is due.
- val intent = Intent().apply {
- action = SleepTimerService.ACTION_CANCEL
- setClassName(context, SleepTimerService::class.java.name)
- }
return PendingIntent.getService(
context,
REQUEST_CODE_CANCEL,
- intent,
+ SleepTimerService.intent(context, SleepTimerService.ACTION_CANCEL),
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
}
diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml
index edb69c8..0ac78e1 100644
--- a/app/src/main/res/values-de/strings.xml
+++ b/app/src/main/res/values-de/strings.xml
@@ -21,6 +21,9 @@
Haptisches Feedback
Vibrieren bei Verwendung von Timer und Bedienelementen
+
+ Wird ausgeblendet
+
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 1996fd7..3df98d1 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -21,6 +21,9 @@
Haptic feedback
Vibrate when using timer and controls
+
+ Fading out
+
diff --git a/core/service/src/main/kotlin/dev/xitee/sleeptimer/core/service/SleepTimerService.kt b/core/service/src/main/kotlin/dev/xitee/sleeptimer/core/service/SleepTimerService.kt
index 170e0c0..9389606 100644
--- a/core/service/src/main/kotlin/dev/xitee/sleeptimer/core/service/SleepTimerService.kt
+++ b/core/service/src/main/kotlin/dev/xitee/sleeptimer/core/service/SleepTimerService.kt
@@ -1,12 +1,14 @@
package dev.xitee.sleeptimer.core.service
import android.app.Service
+import android.content.Context
import android.content.Intent
import android.content.pm.ServiceInfo
import android.os.Build
import android.os.IBinder
import android.os.PowerManager
import android.os.SystemClock
+import android.util.Log
import androidx.core.app.ServiceCompat
import dagger.hilt.android.AndroidEntryPoint
import dev.xitee.sleeptimer.core.data.model.MAX_TIMER_MINUTES
@@ -28,6 +30,7 @@ import dev.xitee.sleeptimer.core.service.shizuku.ShizukuWifiController
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
+import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.cancelAndJoin
@@ -36,6 +39,7 @@ import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
+import kotlinx.coroutines.withContext
import javax.inject.Inject
import kotlin.coroutines.coroutineContext
@@ -85,6 +89,42 @@ class SleepTimerService : Service() {
const val EXTRA_MINUTES = "dev.xitee.sleeptimer.extra.MINUTES"
private const val FADE_IN_SECONDS = 2
private const val MAX_TIMER_MILLIS = MAX_TIMER_MINUTES * 60_000L
+ private const val TAG = "SleepTimerService"
+
+ /**
+ * The explicit intent every dispatch surface must use. Kept here so a class
+ * or package move breaks callers at compile time in one place instead of as
+ * several silent no-op intents.
+ */
+ fun intent(context: Context, actionName: String): Intent =
+ Intent().apply {
+ action = actionName
+ setClassName(context, SleepTimerService::class.java.name)
+ }
+
+ /**
+ * Starts the countdown as a foreground service. Returns false when the
+ * platform denies the start: a background-restricted app (Settings → "Don't
+ * allow background activity") is refused the tap exemption and
+ * startForegroundService throws ForegroundServiceStartNotAllowedException
+ * (an IllegalStateException) — callers get a logged no-op, not a crash.
+ */
+ fun start(context: Context, durationMillis: Long): Boolean =
+ try {
+ context.startForegroundService(
+ intent(context, ACTION_START)
+ .putExtra(EXTRA_DURATION_MILLIS, durationMillis),
+ )
+ true
+ } catch (e: IllegalStateException) {
+ Log.w(TAG, "Foreground service start denied", e)
+ false
+ }
+
+ /** Cancels a running countdown; a stale call is absorbed by the stale-intent guard. */
+ fun cancel(context: Context) {
+ context.startService(intent(context, ACTION_CANCEL))
+ }
}
override fun onCreate() {
@@ -275,20 +315,27 @@ class SleepTimerService : Service() {
countdownJob = null
val generation = timerGeneration
serviceScope.launch {
- // Join the fade-out before restoring volume, otherwise the still-running
- // fade coroutine would overwrite the restored volume at its next step.
- job?.cancelAndJoin()
- mediaVolumeController.restoreVolume()
- // A newer timer may have started while the join was in flight — its
- // foreground session must survive this teardown.
- if (timerGeneration != generation) return@launch
- updateTimerState(TimerPhase.IDLE)
- // Push the idle draw to the widgets before we let the process go: the
- // in-process observer's render could be lost if we're reclaimed right after.
- timerWidgetRefresher.refresh()
- notificationManager.cancelNotification()
- stopForeground(STOP_FOREGROUND_REMOVE)
- stopSelf()
+ // NonCancellable: a second ACTION_CANCEL landing mid-teardown trips the
+ // stale-intent guard's stopSelf → onDestroy → serviceScope.cancel(),
+ // which would otherwise kill this coroutine before the volume restore
+ // and the IDLE write — stranding a phantom "running" phase that the
+ // tile, widget, and app would keep rendering until process death.
+ withContext(NonCancellable) {
+ // Join the fade-out before restoring volume, otherwise the still-running
+ // fade coroutine would overwrite the restored volume at its next step.
+ job?.cancelAndJoin()
+ mediaVolumeController.restoreVolume()
+ // A newer timer may have started while the join was in flight — its
+ // foreground session must survive this teardown.
+ if (timerGeneration != generation) return@withContext
+ updateTimerState(TimerPhase.IDLE)
+ // Push the idle draw to the widgets before we let the process go: the
+ // in-process observer's render could be lost if we're reclaimed right after.
+ timerWidgetRefresher.refresh()
+ notificationManager.cancelNotification()
+ stopForeground(STOP_FOREGROUND_REMOVE)
+ stopSelf()
+ }
}
}
diff --git a/core/service/src/main/kotlin/dev/xitee/sleeptimer/core/service/notification/TimerNotificationManager.kt b/core/service/src/main/kotlin/dev/xitee/sleeptimer/core/service/notification/TimerNotificationManager.kt
index 7e2749f..33c32c4 100644
--- a/core/service/src/main/kotlin/dev/xitee/sleeptimer/core/service/notification/TimerNotificationManager.kt
+++ b/core/service/src/main/kotlin/dev/xitee/sleeptimer/core/service/notification/TimerNotificationManager.kt
@@ -5,7 +5,6 @@ import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
-import android.content.Intent
import androidx.core.app.NotificationCompat
import dagger.hilt.android.qualifiers.ApplicationContext
import dev.xitee.sleeptimer.core.data.model.TimerPhase
@@ -98,18 +97,13 @@ class TimerNotificationManager @Inject constructor(
.build()
}
- private fun servicePendingIntent(actionName: String, requestCode: Int): PendingIntent {
- val intent = Intent().apply {
- action = actionName
- setClassName(context, SleepTimerService::class.java.name)
- }
- return PendingIntent.getService(
+ private fun servicePendingIntent(actionName: String, requestCode: Int): PendingIntent =
+ PendingIntent.getService(
context,
requestCode,
- intent,
+ SleepTimerService.intent(context, actionName),
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
- }
fun updateNotification(
remainingMinutes: Int,
diff --git a/feature/timer/src/main/kotlin/dev/xitee/sleeptimer/feature/timer/timer/TimerViewModel.kt b/feature/timer/src/main/kotlin/dev/xitee/sleeptimer/feature/timer/timer/TimerViewModel.kt
index 6bb0d28..ecfe8e5 100644
--- a/feature/timer/src/main/kotlin/dev/xitee/sleeptimer/feature/timer/timer/TimerViewModel.kt
+++ b/feature/timer/src/main/kotlin/dev/xitee/sleeptimer/feature/timer/timer/TimerViewModel.kt
@@ -2,7 +2,6 @@ package dev.xitee.sleeptimer.feature.timer.timer
import android.content.ComponentName
import android.content.Context
-import android.content.Intent
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
@@ -132,10 +131,10 @@ class TimerViewModel @Inject constructor(
when (state.phase) {
TimerPhase.RUNNING -> {
if (remainingMillisToDisplayMinutes(state.remainingMillis) == coerced) return
- val intent = serviceIntent(SleepTimerService.ACTION_SET_MINUTES).apply {
- putExtra(SleepTimerService.EXTRA_MINUTES, coerced)
- }
- context.startService(intent)
+ context.startService(
+ SleepTimerService.intent(context, SleepTimerService.ACTION_SET_MINUTES)
+ .putExtra(SleepTimerService.EXTRA_MINUTES, coerced),
+ )
}
else -> {
_selectedMinutes.value = coerced
@@ -149,31 +148,21 @@ class TimerViewModel @Inject constructor(
fun startTimer() {
val minutes = _selectedMinutes.value
if (minutes <= 0) return
- val durationMillis = minutes * 60 * 1000L
- val intent = serviceIntent(SleepTimerService.ACTION_START).apply {
- putExtra(SleepTimerService.EXTRA_DURATION_MILLIS, durationMillis)
- }
- context.startForegroundService(intent)
+ SleepTimerService.start(context, minutes * 60 * 1000L)
}
fun stopTimer() {
- context.startService(serviceIntent(SleepTimerService.ACTION_CANCEL))
+ SleepTimerService.cancel(context)
}
fun addStep() {
- context.startService(serviceIntent(SleepTimerService.ACTION_ADD_MINUTES))
+ context.startService(SleepTimerService.intent(context, SleepTimerService.ACTION_ADD_MINUTES))
}
fun subtractStep() {
- context.startService(serviceIntent(SleepTimerService.ACTION_SUBTRACT_MINUTES))
+ context.startService(SleepTimerService.intent(context, SleepTimerService.ACTION_SUBTRACT_MINUTES))
}
- private fun serviceIntent(actionName: String): Intent =
- Intent().apply {
- action = actionName
- setClassName(context, SleepTimerService::class.java.name)
- }
-
private companion object {
// Must match UserSettings().presetMinutes so the compareAndSet in init only
// yields when the user hasn't touched the dial yet.