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.