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
20 changes: 20 additions & 0 deletions app/proguard-rules.pro
Original file line number Diff line number Diff line change
@@ -1,3 +1,23 @@
# Keep Hilt generated classes
-keep class dagger.hilt.** { *; }
-keep class * extends dagger.hilt.android.internal.managers.ViewComponentManager$FragmentContextWrapper { *; }

# kotlinx.serialization: keep the generated $serializer companions for @Serializable
# route objects in navigation/Routes.kt. Navigation-Compose reflects on these at
# runtime to (de)serialize route arguments; without the keep, R8 strips the
# companion metadata and the NavHost fails to resolve destinations in release.
-keepattributes InnerClasses
-keepclassmembers class dev.xitee.sleeptimer.navigation.** {
public static ** Companion;
public static final *** INSTANCE;
kotlinx.serialization.KSerializer serializer(...);
}
-keep,includedescriptorclasses class dev.xitee.sleeptimer.navigation.**$$serializer { *; }

# Shizuku AIDL + provider: the AAR ships consumer-proguard rules, but we reference
# rikka.shizuku.ShizukuProvider by fully-qualified name in AndroidManifest.xml.
# The manifest reference keeps the class; this keeps the IShizukuService AIDL stub
# we use in ShizukuShell.
-keep class moe.shizuku.server.IShizukuService { *; }
-keep class moe.shizuku.server.IShizukuService$Stub { *; }
-keep class moe.shizuku.server.IRemoteProcess { *; }
6 changes: 6 additions & 0 deletions app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,15 @@
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />

<!-- Required on targetSdk 30+ for PackageManager.getPackageInfo() to see Shizuku. -->
<queries>
<package android:name="moe.shizuku.privileged.api" />
</queries>

<application
android:name=".SleepTimerApp"
android:allowBackup="true"
android:dataExtractionRules="@xml/data_extraction_rules"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:supportsRtl="true"
Expand Down
10 changes: 1 addition & 9 deletions app/src/main/res/values-de/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -21,15 +21,7 @@
<string name="haptic_title">Haptisches Feedback</string>
<string name="haptic_description">Vibrieren bei Verwendung von Timer und Bedienelementen</string>

<!-- Notification -->
<string name="notification_channel_name">Schlaf-Timer</string>
<string name="notification_channel_description">Zeigt verbleibende Zeit des Schlaf-Timers</string>
<string name="notification_content_title">Schlaf-Timer</string>
<string name="notification_minutes_remaining">Noch %d Minuten</string>
<string name="notification_less_than_minute">Weniger als eine Minute verbleibend</string>
<string name="notification_action_add_minutes">+%d Min</string>
<string name="notification_action_subtract_minutes">-%d Min</string>
<string name="notification_action_cancel">Abbrechen</string>
<!-- Notification strings live in :core:service to keep them next to the consumer. -->

<!-- Device Admin -->
<string name="device_admin_description">Erforderlich, um den Bildschirm zu sperren, wenn der Schlaf-Timer endet.</string>
Expand Down
10 changes: 1 addition & 9 deletions app/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -21,15 +21,7 @@
<string name="haptic_title">Haptic feedback</string>
<string name="haptic_description">Vibrate when using timer and controls</string>

<!-- Notification -->
<string name="notification_channel_name">Sleep Timer</string>
<string name="notification_channel_description">Shows remaining time for sleep timer</string>
<string name="notification_content_title">Sleep Timer</string>
<string name="notification_minutes_remaining">%d minutes remaining</string>
<string name="notification_less_than_minute">Less than a minute remaining</string>
<string name="notification_action_add_minutes">+%d min</string>
<string name="notification_action_subtract_minutes">-%d min</string>
<string name="notification_action_cancel">Cancel</string>
<!-- Notification strings live in :core:service to keep them next to the consumer. -->

<!-- Device Admin -->
<string name="device_admin_description">Required to lock the screen when the sleep timer ends.</string>
Expand Down
17 changes: 17 additions & 0 deletions app/src/main/res/xml/data_extraction_rules.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Backup/restore rules. DataStore stores all settings in one protobuf file, so we
can't selectively exclude the device-specific gate keys (screen_off, Shizuku
toggles). Compromise: allow device-transfer (local setup wizard) so a user's
prefs follow them to a new phone, but exclude from cloud-backup so a cold
restore onto a different account/device doesn't carry over toggles whose
underlying grants (device admin, Shizuku) are device-local anyway.
The toggles are already silent no-ops at use-time if permissions aren't
granted, so at worst the UI shows stale `on` states until the user reconfigures.
-->
<data-extraction-rules>
<cloud-backup>
<exclude domain="file" path="datastore/settings.preferences_pb" />
</cloud-backup>
<!-- device-transfer: default include keeps all prefs on local-to-local migration. -->
</data-extraction-rules>
Original file line number Diff line number Diff line change
Expand Up @@ -33,18 +33,21 @@ class SettingsRepositoryImpl @Inject constructor(
}

override val settings: Flow<UserSettings> = dataStore.data.map { prefs ->
// Single source of truth: defaults come from UserSettings(), so adding a new
// field only requires updating the data class.
val d = UserSettings()
UserSettings(
stopMediaPlayback = prefs[STOP_MEDIA] ?: true,
fadeOutDurationSeconds = prefs[FADE_OUT_DURATION] ?: 30,
screenOff = prefs[SCREEN_OFF] ?: false,
softScreenOff = prefs[SOFT_SCREEN_OFF] ?: false,
turnOffWifi = prefs[TURN_OFF_WIFI] ?: false,
turnOffBluetooth = prefs[TURN_OFF_BLUETOOTH] ?: false,
hapticFeedbackEnabled = prefs[HAPTIC_FEEDBACK] ?: true,
stopMediaPlayback = prefs[STOP_MEDIA] ?: d.stopMediaPlayback,
fadeOutDurationSeconds = prefs[FADE_OUT_DURATION] ?: d.fadeOutDurationSeconds,
screenOff = prefs[SCREEN_OFF] ?: d.screenOff,
softScreenOff = prefs[SOFT_SCREEN_OFF] ?: d.softScreenOff,
turnOffWifi = prefs[TURN_OFF_WIFI] ?: d.turnOffWifi,
turnOffBluetooth = prefs[TURN_OFF_BLUETOOTH] ?: d.turnOffBluetooth,
hapticFeedbackEnabled = prefs[HAPTIC_FEEDBACK] ?: d.hapticFeedbackEnabled,
theme = ThemeId.fromStorage(prefs[THEME]),
starsEnabled = prefs[STARS_ENABLED] ?: true,
stepMinutes = prefs[STEP_MINUTES] ?: 5,
presetMinutes = prefs[PRESET_MINUTES] ?: 15,
starsEnabled = prefs[STARS_ENABLED] ?: d.starsEnabled,
stepMinutes = prefs[STEP_MINUTES] ?: d.stepMinutes,
presetMinutes = prefs[PRESET_MINUTES] ?: d.presetMinutes,
)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,11 @@ package dev.xitee.sleeptimer.core.data.repository
import dev.xitee.sleeptimer.core.data.model.TimerState
import kotlinx.coroutines.flow.StateFlow

/**
* Read-only view of the timer state, consumed by ViewModels and anywhere else
* that should not mutate state. Mutation lives on [TimerRepositoryImpl] directly
* and is only injected where mutation is intended (currently only the service).
*/
interface TimerRepository {
val timerState: StateFlow<TimerState>
fun updateState(state: TimerState)
}
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,12 @@ class TimerRepositoryImpl @Inject constructor() : TimerRepository {

override val timerState: StateFlow<TimerState> = _timerState.asStateFlow()

override fun updateState(state: TimerState) {
/**
* Mutation intentionally lives on the concrete class, not the interface, so
* read-only consumers (ViewModels) can't write. Only injected where mutation
* is legitimate (currently only SleepTimerService).
*/
fun updateState(state: TimerState) {
_timerState.value = state
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import dev.xitee.sleeptimer.core.data.model.TimerPhase
import dev.xitee.sleeptimer.core.data.model.TimerState
import dev.xitee.sleeptimer.core.data.model.UserSettings
import dev.xitee.sleeptimer.core.data.repository.SettingsRepository
import dev.xitee.sleeptimer.core.data.repository.TimerRepository
import dev.xitee.sleeptimer.core.data.repository.TimerRepositoryImpl
import dev.xitee.sleeptimer.core.data.util.remainingMillisToDisplayMinutes
import dev.xitee.sleeptimer.core.service.media.MediaVolumeController
import dev.xitee.sleeptimer.core.service.notification.TimerNotificationManager
Expand All @@ -36,7 +36,7 @@ import javax.inject.Inject
@AndroidEntryPoint
class SleepTimerService : Service() {

@Inject lateinit var timerRepository: TimerRepository
@Inject lateinit var timerRepository: TimerRepositoryImpl
@Inject lateinit var settingsRepository: SettingsRepository
@Inject lateinit var notificationManager: TimerNotificationManager
@Inject lateinit var mediaVolumeController: MediaVolumeController
Expand Down Expand Up @@ -76,7 +76,17 @@ class SleepTimerService : Service() {
override fun onBind(intent: Intent?): IBinder? = null

override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
when (intent?.action) {
val action = intent?.action
// If the service was restarted by the OS (or by a stale PendingIntent from a
// notification that survived process death) for any action other than START,
// there is no countdown to modify. Skip straight to stopSelf — otherwise we
// would never call startForeground within the 5-second window and crash with
// ForegroundServiceDidNotStartInTimeException.
if (action != ACTION_START && countdownJob == null) {
stopSelf(startId)
return START_NOT_STICKY
}
when (action) {
ACTION_START -> {
val durationMillis = intent.getLongExtra(EXTRA_DURATION_MILLIS, 0L)
if (durationMillis > 0) {
Expand Down

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import androidx.core.app.NotificationCompat
import dagger.hilt.android.qualifiers.ApplicationContext
import dev.xitee.sleeptimer.core.data.model.TimerPhase
import dev.xitee.sleeptimer.core.service.R
import dev.xitee.sleeptimer.core.service.SleepTimerService
import javax.inject.Inject
import javax.inject.Singleton

Expand All @@ -21,10 +22,6 @@ class TimerNotificationManager @Inject constructor(
const val CHANNEL_ID = "sleep_timer_v2"
private const val LEGACY_CHANNEL_ID = "sleep_timer"
const val NOTIFICATION_ID = 1
private const val ACTION_ADD_MINUTES = "dev.xitee.sleeptimer.action.ADD_MINUTES"
private const val ACTION_SUBTRACT_MINUTES = "dev.xitee.sleeptimer.action.SUBTRACT_MINUTES"
private const val ACTION_CANCEL = "dev.xitee.sleeptimer.action.CANCEL"
private const val SERVICE_CLASS = "dev.xitee.sleeptimer.core.service.SleepTimerService"
}

private val notificationManager =
Expand Down Expand Up @@ -73,9 +70,9 @@ class TimerNotificationManager @Inject constructor(
)
}

val subtractPendingIntent = servicePendingIntent(ACTION_SUBTRACT_MINUTES, requestCode = 1)
val addPendingIntent = servicePendingIntent(ACTION_ADD_MINUTES, requestCode = 2)
val cancelPendingIntent = servicePendingIntent(ACTION_CANCEL, requestCode = 3)
val subtractPendingIntent = servicePendingIntent(SleepTimerService.ACTION_SUBTRACT_MINUTES, requestCode = 1)
val addPendingIntent = servicePendingIntent(SleepTimerService.ACTION_ADD_MINUTES, requestCode = 2)
val cancelPendingIntent = servicePendingIntent(SleepTimerService.ACTION_CANCEL, requestCode = 3)

return NotificationCompat.Builder(context, CHANNEL_ID)
.setSmallIcon(R.drawable.ic_timer)
Expand Down Expand Up @@ -104,7 +101,7 @@ class TimerNotificationManager @Inject constructor(
private fun servicePendingIntent(actionName: String, requestCode: Int): PendingIntent {
val intent = Intent().apply {
action = actionName
setClassName(context, SERVICE_CLASS)
setClassName(context, SleepTimerService::class.java.name)
}
return PendingIntent.getService(
context,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import android.util.Log
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeout
import kotlinx.coroutines.TimeoutCancellationException
import moe.shizuku.server.IShizukuService
import rikka.shizuku.Shizuku
import javax.inject.Inject
Expand All @@ -17,6 +19,7 @@ class ShizukuShell @Inject constructor(
/**
* Runs a shell command via Shizuku. Returns true on exit code 0.
* Safe to call when Shizuku is not ready — returns false silently.
* Bounded by [EXEC_TIMEOUT_MS]; a wedged Shizuku binder can't block cancel forever.
*/
suspend fun exec(vararg args: String): Boolean = withContext(Dispatchers.IO) {
if (!shizukuManager.isReady()) {
Expand All @@ -28,7 +31,7 @@ class ShizukuShell @Inject constructor(
val service = IShizukuService.Stub.asInterface(binder)
val remote = service.newProcess(args, null, null)
try {
val exit = remote.waitFor()
val exit = withTimeout(EXEC_TIMEOUT_MS) { remote.waitFor() }
if (exit != 0) {
Log.w(TAG, "cmd=${args.joinToString(" ")} exit=$exit")
}
Expand All @@ -37,6 +40,12 @@ class ShizukuShell @Inject constructor(
try { remote.destroy() } catch (_: Exception) { /* best-effort cleanup */ }
}
} catch (ce: CancellationException) {
// Also covers TimeoutCancellationException (a CancellationException subclass):
// treat a timeout as a silent failure rather than propagating cancellation.
if (ce is TimeoutCancellationException) {
Log.w(TAG, "cmd=${args.joinToString(" ")} timed out after ${EXEC_TIMEOUT_MS}ms")
return@withContext false
}
throw ce
} catch (t: Exception) {
Log.e(TAG, "exec failed: ${args.joinToString(" ")}", t)
Expand All @@ -46,5 +55,8 @@ class ShizukuShell @Inject constructor(

private companion object {
const val TAG = "ShizukuShell"
// `svc wifi disable` etc. complete in milliseconds in practice; 5s is a generous
// ceiling that's still short enough not to delay timer cancel perceptibly.
const val EXEC_TIMEOUT_MS = 5_000L
}
}
8 changes: 6 additions & 2 deletions core/service/src/main/res/drawable/ic_timer.xml
Original file line number Diff line number Diff line change
@@ -1,10 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
No android:tint here: the previous `?attr/colorControlNormal` needed AppCompat,
which core:service doesn't depend on, and notifications re-tint the small icon
using the system accent anyway — the attribute was a no-op at runtime.
-->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="?attr/colorControlNormal">
android:viewportHeight="24">
<path
android:fillColor="@android:color/white"
android:pathData="M11.99,2C6.47,2 2,6.48 2,12s4.47,10 9.99,10C17.52,22 22,17.52 22,12S17.52,2 11.99,2zM12,20c-4.42,0 -8,-3.58 -8,-8s3.58,-8 8,-8 8,3.58 8,8 -3.58,8 -8,8z" />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,8 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.compose.LifecycleEventEffect
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import dev.xitee.sleeptimer.core.service.shizuku.ShizukuManager
import dev.xitee.sleeptimer.feature.timer.R
Expand Down Expand Up @@ -175,6 +177,13 @@ private fun SettingsContent(
viewModel.refreshShizuku()
}

// Device admin can be revoked from system Settings without any broadcast we're
// subscribed to — re-query on resume so the "Screen" row description reflects
// the current admin state if the user came back from Settings → Device admin.
LifecycleEventEffect(Lifecycle.Event.ON_RESUME) {
viewModel.refreshDeviceAdminState()
}

TimerBackground(
animating = false,
starsEnabled = uiState.settings.starsEnabled,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import dagger.hilt.android.qualifiers.ApplicationContext
import dev.xitee.sleeptimer.core.data.model.ThemeId
import dev.xitee.sleeptimer.core.data.repository.SettingsRepository
import dev.xitee.sleeptimer.core.service.shizuku.ShizukuManager
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
Expand All @@ -31,8 +32,18 @@ class SettingsViewModel @Inject constructor(
"dev.xitee.sleeptimer.receiver.SleepTimerDeviceAdminReceiver",
)

// Tick to re-query isAdminActive. Admin grants can be revoked from system Settings
// without any callback into the app, so nothing else drives a refresh. Bumped from
// SettingsScreen on ON_RESUME so returning from Settings → Security → Device admin
// reflects the current state.
private val adminRefreshTicker = MutableStateFlow(0)

val uiState: StateFlow<SettingsUiState?> =
combine(settingsRepository.settings, shizukuManager.state) { settings, shizukuState ->
combine(
settingsRepository.settings,
shizukuManager.state,
adminRefreshTicker,
) { settings, shizukuState, _ ->
SettingsUiState(
settings = settings,
isDeviceAdminEnabled = devicePolicyManager.isAdminActive(adminComponent),
Expand All @@ -46,6 +57,11 @@ class SettingsViewModel @Inject constructor(
fun getAdminComponent(): ComponentName = adminComponent

fun refreshShizuku() = shizukuManager.refresh()

/** Triggers a re-read of the device-admin active flag. */
fun refreshDeviceAdminState() {
adminRefreshTicker.value = adminRefreshTicker.value + 1
}
fun requestShizukuPermission() = shizukuManager.requestPermission()
fun isShizukuReady(): Boolean = shizukuManager.isReady()

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ package dev.xitee.sleeptimer.feature.timer.theme
import androidx.annotation.StringRes
import androidx.compose.runtime.Composable
import androidx.compose.runtime.ReadOnlyComposable
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.runtime.compositionLocalOf
import androidx.compose.ui.graphics.Color
import dev.xitee.sleeptimer.core.data.model.ThemeId

Expand Down Expand Up @@ -59,7 +59,7 @@ data class AppTheme(
companion object
}

val LocalAppTheme = staticCompositionLocalOf { AppThemes.Midnight }
val LocalAppTheme = compositionLocalOf { AppThemes.Midnight }

/** Shortcut: `appTheme()` == `LocalAppTheme.current`. */
@Composable
Expand Down
Loading
Loading