Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -28,13 +28,38 @@
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<!-- Long-pressing the QS tile opens the app instead of the default
App-info screen. -->
<intent-filter>
<action android:name="android.service.quicksettings.action.QS_TILE_PREFERENCES" />
<category android:name="android.intent.category.DEFAULT" />
</intent-filter>
</activity>

<service
android:name="dev.xitee.sleeptimer.core.service.SleepTimerService"
android:exported="false"
android:foregroundServiceType="mediaPlayback" />

<!-- Quick Settings tile: starts the timer with the preset duration. The
tile label/icon here are what the user sees in the QS edit panel.
TOGGLEABLE_TILE deliberately keeps the tile tappable from the lock
screen: starting or cancelling the sleep timer on a nightstand must
not require an unlock. -->
<service
android:name=".tile.SleepTimerTileService"
android:exported="true"
android:icon="@drawable/ic_timer"
android:label="@string/app_name"
android:permission="android.permission.BIND_QUICK_SETTINGS_TILE">
<intent-filter>
<action android:name="android.service.quicksettings.action.QS_TILE" />
</intent-filter>
<meta-data
android:name="android.service.quicksettings.TOGGLEABLE_TILE"
android:value="true" />
</service>

<!-- Optional soft screen-lock via GLOBAL_ACTION_LOCK_SCREEN. exported="true"
is safe: only the system holds BIND_ACCESSIBILITY_SERVICE, and it only
binds after the user enables the service in accessibility settings. -->
Expand Down
135 changes: 135 additions & 0 deletions app/src/main/kotlin/dev/xitee/sleeptimer/tile/SleepTimerTileService.kt
Original file line number Diff line number Diff line change
@@ -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)
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)
}
Expand Down
3 changes: 3 additions & 0 deletions app/src/main/res/values-de/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,9 @@
<string name="haptic_title">Haptisches Feedback</string>
<string name="haptic_description">Vibrieren bei Verwendung von Timer und Bedienelementen</string>

<!-- Quick Settings tile (name and minutes reuse app_name / widget_minutes) -->
<string name="qs_tile_fading_out">Wird ausgeblendet</string>

<!-- Notification strings live in :core:service to keep them next to the consumer. -->

<!-- Device Admin -->
Expand Down
3 changes: 3 additions & 0 deletions app/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,9 @@
<string name="haptic_title">Haptic feedback</string>
<string name="haptic_description">Vibrate when using timer and controls</string>

<!-- Quick Settings tile (name and minutes reuse app_name / widget_minutes) -->
<string name="qs_tile_fading_out">Fading out</string>

<!-- Notification strings live in :core:service to keep them next to the consumer. -->

<!-- Device Admin -->
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
Expand All @@ -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

Expand Down Expand Up @@ -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() {
Expand Down Expand Up @@ -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()
}
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down
Loading
Loading