From d3af7d5b7cd2bc928bb383385620093cbf7071c4 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Wed, 28 Oct 2026 11:55:12 +0100 Subject: [PATCH 1/3] fix(ring): open the ring screen when an alarm or timer goes off in the app A full-screen intent opens its activity only on a locked phone or a dark display. While the user is in Clockula, SystemUI shows it as a heads-up and the ring screen never comes up. Both ring services now start their screen directly when the app has a visible window, which Android allows from the background. Anywhere else the full-screen intent still decides. --- CHANGELOG.md | 3 +- .../clockula/alarm/ring/AlarmRingService.kt | 6 ++- .../clockula/ring/ForegroundRingScreen.kt | 38 +++++++++++++++++++ .../clockula/timer/service/TimerService.kt | 4 ++ 4 files changed, 49 insertions(+), 2 deletions(-) create mode 100644 app/src/main/java/de/jeanlucmakiola/clockula/ring/ForegroundRingScreen.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index a7580d0..a9cff47 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -47,7 +47,8 @@ The first release of Clockula: alarms, timers, a stopwatch and a world clock. default sound instead. - A ring screen over the lock screen with snooze and dismiss. The dismiss challenge always gives you a way out, works with a screen reader, and Back - can't silence an alarm. + can't silence an alarm. It also opens when the alarm or a timer goes off + while you're using Clockula. - Choose what the volume buttons do while an alarm rings: change the volume (as before), snooze, dismiss or nothing. Dismiss still respects the dismiss challenge by snoozing instead, and a snooze press with no snoozes left diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/alarm/ring/AlarmRingService.kt b/app/src/main/java/de/jeanlucmakiola/clockula/alarm/ring/AlarmRingService.kt index 1b18bd8..fb10699 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/alarm/ring/AlarmRingService.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/alarm/ring/AlarmRingService.kt @@ -18,8 +18,10 @@ import de.jeanlucmakiola.clockula.domain.alarm.RingPresentationPolicy import de.jeanlucmakiola.clockula.domain.alarm.RingSession import de.jeanlucmakiola.clockula.domain.ring.RingFallbackPolicy import de.jeanlucmakiola.clockula.domain.ring.SilenceAfter +import de.jeanlucmakiola.clockula.ring.ForegroundRingScreen import de.jeanlucmakiola.clockula.ring.RingAudioPlayer import de.jeanlucmakiola.clockula.ring.RingVibrator +import de.jeanlucmakiola.clockula.ui.ring.AlarmRingActivity import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job @@ -128,10 +130,12 @@ class AlarmRingService : Service() { * replaces it with the alarm's own notification as soon as the session is * read. Same channel and same id, so the user sees one notification — and * the placeholder is the first post, so it is the one that opens the ring - * screen. + * screen. Inside the app the full-screen intent only shows a heads-up, + * so the screen is opened directly there. */ private fun startForegroundPreparing() { startForeground(RingNotifications.preparing(this, presentation(), repostPendingIntent())) + ForegroundRingScreen.launchIfVisible(this, AlarmRingActivity::class.java) } private fun startForeground(session: RingSession) { diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ring/ForegroundRingScreen.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ring/ForegroundRingScreen.kt new file mode 100644 index 0000000..e3b05d9 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ring/ForegroundRingScreen.kt @@ -0,0 +1,38 @@ +package de.jeanlucmakiola.clockula.ring + +import android.app.Activity +import android.app.ActivityManager +import android.content.Context +import android.content.Intent + +/** + * Opens a ring screen directly while one of Clockula's own windows is on + * screen. + * + * A full-screen intent opens its activity only when the phone is locked or the + * display is off. On a phone in use, and so always while the user is inside + * Clockula, SystemUI shows it as a heads-up and the ring screen never comes up. + * An app with a visible window may start activities from the background, so + * the ring services start the screen themselves in that one case. Everywhere + * else the full-screen intent stays in charge, and a heads-up over another app + * is what the platform asks for. + */ +object ForegroundRingScreen { + + fun launchIfVisible(context: Context, screen: Class) { + if (!appIsVisible()) return + // A refused start costs nothing: the notification is up either way. + runCatching { + context.startActivity( + Intent(context, screen).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK), + ) + } + } + + /** The process holds a visible activity, not only a foreground service. */ + private fun appIsVisible(): Boolean { + val process = ActivityManager.RunningAppProcessInfo() + ActivityManager.getMyMemoryState(process) + return process.importance <= ActivityManager.RunningAppProcessInfo.IMPORTANCE_VISIBLE + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/timer/service/TimerService.kt b/app/src/main/java/de/jeanlucmakiola/clockula/timer/service/TimerService.kt index 7ed553a..e3f7048 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/timer/service/TimerService.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/timer/service/TimerService.kt @@ -20,12 +20,14 @@ import de.jeanlucmakiola.clockula.domain.timer.TimerMode import de.jeanlucmakiola.clockula.domain.timer.TimerRing import de.jeanlucmakiola.clockula.domain.timer.TimerRingDecision import de.jeanlucmakiola.clockula.domain.timer.TimerSettings +import de.jeanlucmakiola.clockula.ring.ForegroundRingScreen import de.jeanlucmakiola.clockula.ring.RingAudioPlayer import de.jeanlucmakiola.clockula.ring.RingVibrator import de.jeanlucmakiola.clockula.timer.AlarmRingStatus import de.jeanlucmakiola.clockula.timer.TimerEngine import de.jeanlucmakiola.clockula.timer.TimerIntents import de.jeanlucmakiola.clockula.timer.TimerServiceState +import de.jeanlucmakiola.clockula.ui.ring.TimerRingActivity import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job @@ -286,6 +288,8 @@ class TimerService : Service() { fullScreen = sounding && capabilities.snapshot().canUseFullScreenIntent } startForeground(TimerNotifications.timer(this, state, fullScreen)) + // Inside the app the full-screen intent only shows a heads-up. + if (!state.alertOnce && sounding) ForegroundRingScreen.launchIfVisible(this, TimerRingActivity::class.java) } /** From baffa9a188a608cd3b68bc2341941be0fe2311fe Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Wed, 28 Oct 2026 12:01:43 +0100 Subject: [PATCH 2/3] feat(ring): optionally open the ring screen over other apps A full-screen intent is only a heads-up on a phone in use. A new, opt-in setting under Alarms lets a ringing alarm or timer open its screen over the app in use instead. It relies on "display over other apps", which turning the setting on sends the user to grant; the switch reads as off while the permission is missing. A locked or dark phone is still left to the full-screen intent, so the screen never opens twice. --- CHANGELOG.md | 3 +- app/src/main/AndroidManifest.xml | 5 ++ .../clockula/alarm/ring/AlarmRingService.kt | 15 ++++-- .../clockula/data/prefs/ClockPrefs.kt | 8 ++++ .../clockula/data/prefs/SettingsPrefs.kt | 6 +++ .../clockula/ring/ForegroundRingScreen.kt | 31 +++++++++---- .../clockula/timer/service/TimerService.kt | 9 ++-- .../ui/settings/AlarmDefaultsScreen.kt | 46 ++++++++++++++++++- .../clockula/ui/settings/SettingsUiState.kt | 2 + .../clockula/ui/settings/SettingsViewModel.kt | 8 +++- app/src/main/res/values/strings.xml | 2 + .../clockula/ManifestRulesTest.kt | 3 ++ docs/ARCHITECTURE.md | 1 + docs/PRIVACY.md | 3 ++ 14 files changed, 122 insertions(+), 20 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a9cff47..9538fc5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -48,7 +48,8 @@ The first release of Clockula: alarms, timers, a stopwatch and a world clock. - A ring screen over the lock screen with snooze and dismiss. The dismiss challenge always gives you a way out, works with a screen reader, and Back can't silence an alarm. It also opens when the alarm or a timer goes off - while you're using Clockula. + while you're using Clockula, and with "Ring screen over other apps" turned + on it opens over any app instead of a notification. - Choose what the volume buttons do while an alarm rings: change the volume (as before), snooze, dismiss or nothing. Dismiss still respects the dismiss challenge by snoozing instead, and a snooze press with no snoozes left diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 4952c72..dc04251 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -24,6 +24,11 @@ heads-up notification that still sounds — never to silence. --> + + + diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/alarm/ring/AlarmRingService.kt b/app/src/main/java/de/jeanlucmakiola/clockula/alarm/ring/AlarmRingService.kt index fb10699..e75ed94 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/alarm/ring/AlarmRingService.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/alarm/ring/AlarmRingService.kt @@ -14,6 +14,7 @@ import dagger.hilt.android.AndroidEntryPoint import de.jeanlucmakiola.clockula.alarm.AlarmCapabilities import de.jeanlucmakiola.clockula.alarm.AlarmEngine import de.jeanlucmakiola.clockula.alarm.AlarmIntents +import de.jeanlucmakiola.clockula.data.prefs.SettingsPrefs import de.jeanlucmakiola.clockula.domain.alarm.RingPresentationPolicy import de.jeanlucmakiola.clockula.domain.alarm.RingSession import de.jeanlucmakiola.clockula.domain.ring.RingFallbackPolicy @@ -27,6 +28,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch import javax.inject.Inject @@ -48,6 +50,9 @@ class AlarmRingService : Service() { @Inject lateinit var capabilities: AlarmCapabilities + @Inject + lateinit var settings: SettingsPrefs + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate) private val audio by lazy { RingAudioPlayer(this) } @@ -95,6 +100,12 @@ class AlarmRingService : Service() { return@launch } ring(session) + // Where the full-screen intent would only show a heads-up. + ForegroundRingScreen.launch( + context = this@AlarmRingService, + screen = AlarmRingActivity::class.java, + overOtherApps = settings.ringOverOtherApps.first(), + ) } // Re-delivered rather than dropped: a killed ring is resumed with the // same intent, and the session is re-read from storage anyway. @@ -130,12 +141,10 @@ class AlarmRingService : Service() { * replaces it with the alarm's own notification as soon as the session is * read. Same channel and same id, so the user sees one notification — and * the placeholder is the first post, so it is the one that opens the ring - * screen. Inside the app the full-screen intent only shows a heads-up, - * so the screen is opened directly there. + * screen. */ private fun startForegroundPreparing() { startForeground(RingNotifications.preparing(this, presentation(), repostPendingIntent())) - ForegroundRingScreen.launchIfVisible(this, AlarmRingActivity::class.java) } private fun startForeground(session: RingSession) { diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/data/prefs/ClockPrefs.kt b/app/src/main/java/de/jeanlucmakiola/clockula/data/prefs/ClockPrefs.kt index daa9aef..c584859 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/data/prefs/ClockPrefs.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/data/prefs/ClockPrefs.kt @@ -74,6 +74,14 @@ object ClockPrefs { */ val worldClockSeconds: Pref = booleanPref("world_clock_show_seconds", false) + /** + * Whether a ringing alarm or timer opens its screen over another app + * rather than as a heads-up. Off by default, and only in effect while the + * "display over other apps" permission is granted, which turning it on + * asks for. Not backed up: the permission does not travel with it. + */ + val ringOverOtherApps: Pref = booleanPref("ring_screen_over_other_apps", false) + /** * The World clock tab's temporary local-time row while the device is in a * zone whose time differs from home (#24). On by default, as Google Clock's diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/data/prefs/SettingsPrefs.kt b/app/src/main/java/de/jeanlucmakiola/clockula/data/prefs/SettingsPrefs.kt index 3778830..755c879 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/data/prefs/SettingsPrefs.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/data/prefs/SettingsPrefs.kt @@ -191,6 +191,12 @@ class SettingsPrefs @Inject constructor(private val store: PrefStore) { suspend fun setWorldClockSeconds(enabled: Boolean) = store.set(ClockPrefs.worldClockSeconds, enabled) + // --- ring screen over other apps --- + + val ringOverOtherApps: Flow = store.flow(ClockPrefs.ringOverOtherApps) + + suspend fun setRingOverOtherApps(enabled: Boolean) = store.set(ClockPrefs.ringOverOtherApps, enabled) + // --- timer presets (M6) --- /** diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ring/ForegroundRingScreen.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ring/ForegroundRingScreen.kt index e3b05d9..9bdac83 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/ring/ForegroundRingScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ring/ForegroundRingScreen.kt @@ -2,25 +2,31 @@ package de.jeanlucmakiola.clockula.ring import android.app.Activity import android.app.ActivityManager +import android.app.KeyguardManager import android.content.Context import android.content.Intent +import android.os.PowerManager +import android.provider.Settings /** - * Opens a ring screen directly while one of Clockula's own windows is on - * screen. + * Opens a ring screen directly where a full-screen intent would only show a + * heads-up. * * A full-screen intent opens its activity only when the phone is locked or the - * display is off. On a phone in use, and so always while the user is inside - * Clockula, SystemUI shows it as a heads-up and the ring screen never comes up. - * An app with a visible window may start activities from the background, so - * the ring services start the screen themselves in that one case. Everywhere - * else the full-screen intent stays in charge, and a heads-up over another app - * is what the platform asks for. + * display is off. On a phone in use SystemUI shows it as a heads-up, so the + * ring screen never comes up, not even while the user is inside Clockula. + * Android lets an app start activities from the background while it has a + * visible window, or while it holds "display over other apps". The ring + * services use the first always, and the second only when the user turned on + * [overOtherApps]. A locked or dark phone is left to the full-screen intent, + * so the screen is never opened twice. */ object ForegroundRingScreen { - fun launchIfVisible(context: Context, screen: Class) { - if (!appIsVisible()) return + fun launch(context: Context, screen: Class, overOtherApps: Boolean) { + val allowed = appIsVisible() || + (overOtherApps && Settings.canDrawOverlays(context) && phoneInUse(context)) + if (!allowed) return // A refused start costs nothing: the notification is up either way. runCatching { context.startActivity( @@ -35,4 +41,9 @@ object ForegroundRingScreen { ActivityManager.getMyMemoryState(process) return process.importance <= ActivityManager.RunningAppProcessInfo.IMPORTANCE_VISIBLE } + + /** Display on and unlocked: the one case the full-screen intent hands to a heads-up. */ + private fun phoneInUse(context: Context): Boolean = + context.getSystemService(PowerManager::class.java).isInteractive && + !context.getSystemService(KeyguardManager::class.java).isKeyguardLocked } diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/timer/service/TimerService.kt b/app/src/main/java/de/jeanlucmakiola/clockula/timer/service/TimerService.kt index e3f7048..6f04361 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/timer/service/TimerService.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/timer/service/TimerService.kt @@ -37,6 +37,7 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.merge import kotlinx.coroutines.launch import kotlin.time.Duration @@ -274,7 +275,7 @@ class TimerService : Service() { } } - private fun post(state: TimerNotificationState, sounding: Boolean) { + private suspend fun post(state: TimerNotificationState, sounding: Boolean) { var fullScreen = false if (!state.alertOnce) { alreadyAlerted = true @@ -288,8 +289,10 @@ class TimerService : Service() { fullScreen = sounding && capabilities.snapshot().canUseFullScreenIntent } startForeground(TimerNotifications.timer(this, state, fullScreen)) - // Inside the app the full-screen intent only shows a heads-up. - if (!state.alertOnce && sounding) ForegroundRingScreen.launchIfVisible(this, TimerRingActivity::class.java) + // Where the full-screen intent would only show a heads-up. + if (!state.alertOnce && sounding) { + ForegroundRingScreen.launch(this, TimerRingActivity::class.java, settings.ringOverOtherApps.first()) + } } /** diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/settings/AlarmDefaultsScreen.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/settings/AlarmDefaultsScreen.kt index 7029d2d..09b4a5e 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/ui/settings/AlarmDefaultsScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/settings/AlarmDefaultsScreen.kt @@ -1,15 +1,22 @@ package de.jeanlucmakiola.clockula.ui.settings +import android.content.Intent +import android.net.Uri +import android.provider.Settings import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.height import androidx.compose.runtime.Composable +import androidx.compose.material3.Switch import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.LifecycleResumeEffect import de.jeanlucmakiola.clockula.R import de.jeanlucmakiola.clockula.domain.alarm.VolumeButtonAction import de.jeanlucmakiola.clockula.ui.alarms.DismissChallengePicker @@ -32,7 +39,8 @@ private enum class OpenAlarmPicker { VIBRATE, VOLUME_RAMP, SNOOZE_LENGTH, SNOOZE /** * What every new alarm starts with: how it sounds, how it snoozes, and how it - * is dismissed; then how long any alarm rings, which no alarm overrides (#17). + * is dismissed; then how long any alarm rings, which no alarm overrides (#17), + * and whether a ring screen may open over another app. * The ringtone picker itself is ViewModel state, drawn by the hub. */ @Composable @@ -107,6 +115,8 @@ internal fun AlarmDefaultsScreen( onClick = { openPicker = OpenAlarmPicker.VOLUME_BUTTONS }, ) SettingsHint(stringResource(R.string.settings_volume_buttons_hint)) + Spacer(Modifier.height(24.dp)) + RingOverOtherAppsRow(state.ringOverOtherApps, viewModel::onRingOverOtherAppsChanged) } when (openPicker) { @@ -171,3 +181,37 @@ private fun volumeButtonsText(action: VolumeButtonAction): String = stringResour VolumeButtonAction.NOTHING -> R.string.volume_buttons_nothing }, ) + +/** + * On only while both the choice and the permission are there, so a permission + * revoked in system settings shows as off. Turning it on without the + * permission stores the choice and opens the system page that grants it; the + * switch catches up on return. + */ +@Composable +private fun RingOverOtherAppsRow(stored: Boolean, onChange: (Boolean) -> Unit) { + val context = LocalContext.current + var permitted by remember { mutableStateOf(Settings.canDrawOverlays(context)) } + LifecycleResumeEffect(Unit) { + permitted = Settings.canDrawOverlays(context) + onPauseOrDispose {} + } + val checked = stored && permitted + val toggle = { enabled: Boolean -> + onChange(enabled) + if (enabled && !permitted) { + runCatching { + context.startActivity( + Intent(Settings.ACTION_MANAGE_OVERLAY_PERMISSION, Uri.parse("package:${context.packageName}")), + ) + } + } + } + GroupedRow( + title = stringResource(R.string.settings_ring_over_other_apps), + summary = stringResource(R.string.settings_ring_over_other_apps_hint), + position = Position.Alone, + trailing = { Switch(checked = checked, onCheckedChange = { toggle(it) }) }, + onClick = { toggle(!checked) }, + ) +} diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/settings/SettingsUiState.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/settings/SettingsUiState.kt index 80a6372..50ef7cf 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/ui/settings/SettingsUiState.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/settings/SettingsUiState.kt @@ -16,6 +16,8 @@ internal sealed interface SettingsUiState { val selfCheckProblemCount: Int? = null, /** The world clock's "Display time with seconds" (#23); not a clock default. */ val worldClockSeconds: Boolean, + /** The stored choice; the screen pairs it with the overlay permission. */ + val ringOverOtherApps: Boolean = false, ) : SettingsUiState } diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/settings/SettingsViewModel.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/settings/SettingsViewModel.kt index 6977d46..6d53dde 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/ui/settings/SettingsViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/settings/SettingsViewModel.kt @@ -73,8 +73,9 @@ class SettingsViewModel @Inject internal constructor( settings.defaults, selfCheckProblemCount, settings.worldClockSeconds, - ) { appearance, defaults, problemCount, worldClockSeconds -> - SettingsUiState.Loaded(appearance, defaults, problemCount, worldClockSeconds) + settings.ringOverOtherApps, + ) { appearance, defaults, problemCount, worldClockSeconds, ringOverOtherApps -> + SettingsUiState.Loaded(appearance, defaults, problemCount, worldClockSeconds, ringOverOtherApps) }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(SUBSCRIPTION_GRACE_MS), SettingsUiState.Loading) /** @@ -253,6 +254,9 @@ class SettingsViewModel @Inject internal constructor( fun onWorldClockSecondsChanged(enabled: Boolean) = viewModelScope.launch { settings.setWorldClockSeconds(enabled) } + fun onRingOverOtherAppsChanged(enabled: Boolean) = + viewModelScope.launch { settings.setRingOverOtherApps(enabled) } + // --- the home time-zone picker --- fun onOpenHomeZonePicker() { diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 3138ce7..b96f9c9 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -344,6 +344,8 @@ Volume buttons while ringing Applies to every alarm while its ring screen is showing. With a dismiss challenge set, Dismiss snoozes instead, so the challenge still has to be done. When a snooze is called for but none are left, the buttons change the volume instead and the alarm keeps ringing. + Ring screen over other apps + When an alarm or timer goes off while you\'re using another app, show its ring screen instead of a notification. Needs permission to display over other apps. Change the volume Snooze Dismiss diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/ManifestRulesTest.kt b/app/src/test/java/de/jeanlucmakiola/clockula/ManifestRulesTest.kt index 1bde42d..abd11bc 100644 --- a/app/src/test/java/de/jeanlucmakiola/clockula/ManifestRulesTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/clockula/ManifestRulesTest.kt @@ -285,6 +285,9 @@ class ManifestRulesTest { "android.permission.SCHEDULE_EXACT_ALARM", "android.permission.RECEIVE_BOOT_COMPLETED", "android.permission.USE_FULL_SCREEN_INTENT", + // The opt-in ring screen over other apps. Granted by hand, and + // only after the user turns the setting on. + "android.permission.SYSTEM_ALERT_WINDOW", "android.permission.POST_NOTIFICATIONS", // Already present for the timer's Live Update chip; not added by // M10, which — per docs/PLAN.md's amended §1 #2 — adds no permission diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 9e3b983..162b213 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -996,6 +996,7 @@ declared because a specific path would otherwise fail, silently, at 07:00. | `WAKE_LOCK` | keep the CPU up for the length of a ring | | `VIBRATE` | an alarm and a timer both vibrate, and vibrate alone when no audio source opens | | `ACCESS_NOTIFICATION_POLICY` | issue #1's opt-in bedtime mode. Install-time, and on its own it does nothing: it lists Clockula under Special app access › Do Not Disturb, which the user grants by hand. With that grant Clockula adds one Do Not Disturb rule of its own, which always lets alarms through (§18) | +| `SYSTEM_ALERT_WINDOW` | the opt-in "Ring screen over other apps". Granted by hand from the system page the setting opens. It is used only as Android's background-activity-start exemption, so a ring screen opens over the app in use where a full-screen intent would be a heads-up. Clockula draws no overlay window | **M6 adds no permission at all.** A timer never takes over the screen (no full-screen intent, no ring activity, no `showWhenLocked`), because its user set diff --git a/docs/PRIVACY.md b/docs/PRIVACY.md index 385d5ec..b4834d0 100644 --- a/docs/PRIVACY.md +++ b/docs/PRIVACY.md @@ -171,6 +171,9 @@ on. Clockula only opens the address and sends no data of yours with it. Clockula Do Not Disturb access yourself. Clockula then adds one Do Not Disturb mode of its own and turns it on and off on your schedule. It does not read your notifications. +- `SYSTEM_ALERT_WINDOW`: only used if you turn on "Ring screen over other apps" + and grant it yourself. A ringing alarm or timer then opens its ring screen + over the app you're using. Clockula draws nothing else over other apps. - `ACCESS_NETWORK_STATE`: added by the Android library that updates the home-screen widgets (WorkManager). It lets an app see whether a network connection exists. It does not let it use one, and without `INTERNET` no From d584a1a9a54609b6628c5b46f45015a43287f3ef Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Wed, 28 Oct 2026 12:11:01 +0100 Subject: [PATCH 3/3] fix(ring): open the ring screen directly only where the policy allows The visibility check counted the ring service's own foreground service as a visible window, so every ring opened its screen directly: over any app without the setting, and on a locked phone on top of the full-screen intent. Started activities are now tracked through lifecycle callbacks. The decision moves into DirectLaunchPolicy with JVM tests. It needs a phone in use, leaves a screen already showing this ring alone so a restart no longer resets a dismiss challenge, and never covers a call from another app. The switch stores the choice only once the overlay permission is granted and clears it when the permission is gone, so it can't stick or come back on by itself. It falls back to the app details page where the permission page is missing. One shared intent opens the ring screens, and the architecture notes no longer say a timer never takes the screen. --- .../de/jeanlucmakiola/clockula/ClockulaApp.kt | 2 + .../clockula/alarm/ring/AlarmRingService.kt | 27 ++++-- .../clockula/alarm/ring/RingNotifications.kt | 5 +- .../clockula/data/prefs/ClockPrefs.kt | 7 +- .../clockula/domain/alarm/RingPolicies.kt | 31 +++++++ .../clockula/ring/ForegroundRingScreen.kt | 83 ++++++++++++++----- .../timer/service/TimerNotifications.kt | 4 +- .../clockula/timer/service/TimerService.kt | 9 +- .../ui/settings/AlarmDefaultsScreen.kt | 56 +++++++++---- .../clockula/ui/settings/SettingsUiState.kt | 2 +- .../domain/alarm/DirectLaunchPolicyTest.kt | 51 ++++++++++++ docs/ARCHITECTURE.md | 15 ++-- 12 files changed, 233 insertions(+), 59 deletions(-) create mode 100644 app/src/test/java/de/jeanlucmakiola/clockula/domain/alarm/DirectLaunchPolicyTest.kt diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ClockulaApp.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ClockulaApp.kt index 34cf08b..25d9d8e 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/ClockulaApp.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ClockulaApp.kt @@ -8,6 +8,7 @@ import de.jeanlucmakiola.clockula.alarm.ring.UpcomingNotifications import de.jeanlucmakiola.clockula.bedtime.BedtimeEngine import de.jeanlucmakiola.clockula.bedtime.android.AndroidBedtimeNotifier import de.jeanlucmakiola.clockula.data.prefs.SettingsPrefs +import de.jeanlucmakiola.clockula.ring.ForegroundRingScreen import de.jeanlucmakiola.clockula.stopwatch.StopwatchEngine import de.jeanlucmakiola.clockula.stopwatch.service.StopwatchNotifications import de.jeanlucmakiola.clockula.timer.TimerEngine @@ -69,6 +70,7 @@ class ClockulaApp : Application() { ), ) + ForegroundRingScreen.track(this) RingNotifications.createChannels(this) UpcomingNotifications.createChannel(this) TimerNotifications.createChannel(this) diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/alarm/ring/AlarmRingService.kt b/app/src/main/java/de/jeanlucmakiola/clockula/alarm/ring/AlarmRingService.kt index e75ed94..45cf88d 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/alarm/ring/AlarmRingService.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/alarm/ring/AlarmRingService.kt @@ -61,6 +61,9 @@ class AlarmRingService : Service() { private var wakeLock: PowerManager.WakeLock? = null private var ringing: Job? = null + /** The alarm this service last asked a ring screen for. */ + private var screenFor: Long? = null + /** The notification last posted, put back as it was when the user swipes it away. */ private var posted: Notification? = null @@ -100,12 +103,7 @@ class AlarmRingService : Service() { return@launch } ring(session) - // Where the full-screen intent would only show a heads-up. - ForegroundRingScreen.launch( - context = this@AlarmRingService, - screen = AlarmRingActivity::class.java, - overOtherApps = settings.ringOverOtherApps.first(), - ) + openScreen(session) } // Re-delivered rather than dropped: a killed ring is resumed with the // same intent, and the session is re-read from storage anyway. @@ -136,6 +134,23 @@ class AlarmRingService : Service() { ) } + /** + * Where the full-screen intent would only show a heads-up. The screen + * reads its session once, so one already up is left alone only when it + * is this alarm's: a restart must not reset a half-done challenge, but + * an alarm that took over needs a screen of its own. + */ + private suspend fun openScreen(session: RingSession) { + val screen = AlarmRingActivity::class.java + ForegroundRingScreen.launch( + context = this, + screen = screen, + screenShowing = screenFor == session.alarm.id && ForegroundRingScreen.isShowing(screen), + overOtherApps = settings.ringOverOtherApps.first(), + ) + screenFor = session.alarm.id + } + /** * The placeholder the service goes foreground with immediately; [ring] * replaces it with the alarm's own notification as soon as the session is diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/alarm/ring/RingNotifications.kt b/app/src/main/java/de/jeanlucmakiola/clockula/alarm/ring/RingNotifications.kt index 17a22d1..892c1e7 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/alarm/ring/RingNotifications.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/alarm/ring/RingNotifications.kt @@ -14,6 +14,7 @@ import de.jeanlucmakiola.clockula.domain.Alarm import de.jeanlucmakiola.clockula.domain.DismissChallenge import de.jeanlucmakiola.clockula.domain.alarm.RingPresentation import de.jeanlucmakiola.clockula.domain.alarm.RingSession +import de.jeanlucmakiola.clockula.ring.ForegroundRingScreen import de.jeanlucmakiola.clockula.ui.ring.AlarmRingActivity import java.time.Instant as JavaInstant import java.time.LocalTime @@ -190,9 +191,7 @@ object RingNotifications { private fun showPendingIntent(context: Context): PendingIntent = PendingIntent.getActivity( context, AlarmIntents.REQUEST_SHOW, - Intent(context, AlarmRingActivity::class.java).apply { - flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK - }, + ForegroundRingScreen.intent(context, AlarmRingActivity::class.java), PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, ) diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/data/prefs/ClockPrefs.kt b/app/src/main/java/de/jeanlucmakiola/clockula/data/prefs/ClockPrefs.kt index c584859..452ac96 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/data/prefs/ClockPrefs.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/data/prefs/ClockPrefs.kt @@ -77,8 +77,11 @@ object ClockPrefs { /** * Whether a ringing alarm or timer opens its screen over another app * rather than as a heads-up. Off by default, and only in effect while the - * "display over other apps" permission is granted, which turning it on - * asks for. Not backed up: the permission does not travel with it. + * "display over other apps" permission is granted. Stored only once that + * permission is, and cleared when the settings screen finds it gone, so a + * restore that brings the value back without the permission cannot leave + * the feature waiting to switch itself on. Not in Clockula's own backup + * file, since the permission does not travel with it. */ val ringOverOtherApps: Pref = booleanPref("ring_screen_over_other_apps", false) diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/domain/alarm/RingPolicies.kt b/app/src/main/java/de/jeanlucmakiola/clockula/domain/alarm/RingPolicies.kt index 810ed74..f5bdd2f 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/domain/alarm/RingPolicies.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/domain/alarm/RingPolicies.kt @@ -27,6 +27,37 @@ object RingPresentationPolicy { ) } +/** + * What decides whether a ring service opens its screen itself rather than + * leaving it to the full-screen intent. All read at the moment of the ring. + */ +data class DirectLaunchFacts( + /** Display on and unlocked: the one state where a full-screen intent is only a heads-up. */ + val phoneInUse: Boolean, + /** One of Clockula's own activities is started, which lets it start another. */ + val appVisible: Boolean, + /** The ring screen for this very ring is already up. */ + val screenShowing: Boolean, + /** The user's "Ring screen over other apps". */ + val overOtherApps: Boolean, + /** "Display over other apps", without which [overOtherApps] can do nothing. */ + val canDrawOverlays: Boolean, + /** A call is in progress, whose screen a ring must not cover. */ + val inCall: Boolean, +) + +object DirectLaunchPolicy { + /** + * A locked or dark phone is the full-screen intent's, so the screen never + * opens twice; an open screen is left alone, so a restart never resets it. + * Over another app only when the user asked for it, the permission is + * there, and no call is going on. + */ + fun opensDirectly(facts: DirectLaunchFacts): Boolean = with(facts) { + phoneInUse && !screenShowing && (appVisible || (overOtherApps && canDrawOverlays && !inCall)) + } +} + enum class ScheduleMode { EXACT_ALARM_CLOCK, INEXACT_FALLBACK } object SchedulingPolicy { diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ring/ForegroundRingScreen.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ring/ForegroundRingScreen.kt index 9bdac83..6ea5202 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/ring/ForegroundRingScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ring/ForegroundRingScreen.kt @@ -1,12 +1,16 @@ package de.jeanlucmakiola.clockula.ring import android.app.Activity -import android.app.ActivityManager +import android.app.Application import android.app.KeyguardManager import android.content.Context import android.content.Intent +import android.media.AudioManager +import android.os.Bundle import android.os.PowerManager import android.provider.Settings +import de.jeanlucmakiola.clockula.domain.alarm.DirectLaunchFacts +import de.jeanlucmakiola.clockula.domain.alarm.DirectLaunchPolicy /** * Opens a ring screen directly where a full-screen intent would only show a @@ -16,34 +20,71 @@ import android.provider.Settings * display is off. On a phone in use SystemUI shows it as a heads-up, so the * ring screen never comes up, not even while the user is inside Clockula. * Android lets an app start activities from the background while it has a - * visible window, or while it holds "display over other apps". The ring - * services use the first always, and the second only when the user turned on - * [overOtherApps]. A locked or dark phone is left to the full-screen intent, - * so the screen is never opened twice. + * visible window, or while it holds "display over other apps". When either + * applies is [DirectLaunchPolicy]'s to say; this object only reads the facts. */ object ForegroundRingScreen { - fun launch(context: Context, screen: Class, overOtherApps: Boolean) { - val allowed = appIsVisible() || - (overOtherApps && Settings.canDrawOverlays(context) && phoneInUse(context)) - if (!allowed) return + /** Every activity of this process between its onStart and its onStop. */ + private val started = mutableListOf() + + /** + * Registered once from the Application, so the started set is known + * before anything can ring. A foreground service does not count: the ring + * services are one at the moment they ask. + */ + fun track(app: Application) { + app.registerActivityLifecycleCallbacks(object : Application.ActivityLifecycleCallbacks { + override fun onActivityStarted(activity: Activity) { + started += activity + } + + override fun onActivityStopped(activity: Activity) { + started -= activity + } + + override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) = Unit + override fun onActivityResumed(activity: Activity) = Unit + override fun onActivityPaused(activity: Activity) = Unit + override fun onActivitySaveInstanceState(activity: Activity, outState: Bundle) = Unit + override fun onActivityDestroyed(activity: Activity) = Unit + }) + } + + /** Whether a [screen] is up and not on its way out. */ + fun isShowing(screen: Class): Boolean = + started.any { it.javaClass == screen && !it.isFinishing } + + /** + * Opens [screen] if the policy says so. [screenShowing] is the caller's: + * only it knows whether an open screen belongs to this very ring. + */ + fun launch(context: Context, screen: Class, screenShowing: Boolean, overOtherApps: Boolean) { + val facts = DirectLaunchFacts( + phoneInUse = phoneInUse(context), + appVisible = started.isNotEmpty(), + screenShowing = screenShowing, + overOtherApps = overOtherApps, + canDrawOverlays = Settings.canDrawOverlays(context), + inCall = inCall(context), + ) + if (!DirectLaunchPolicy.opensDirectly(facts)) return // A refused start costs nothing: the notification is up either way. - runCatching { - context.startActivity( - Intent(context, screen).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK), - ) - } + runCatching { context.startActivity(intent(context, screen)) } } - /** The process holds a visible activity, not only a foreground service. */ - private fun appIsVisible(): Boolean { - val process = ActivityManager.RunningAppProcessInfo() - ActivityManager.getMyMemoryState(process) - return process.importance <= ActivityManager.RunningAppProcessInfo.IMPORTANCE_VISIBLE - } + /** The one way a ring screen is opened, directly or by a full-screen intent. */ + fun intent(context: Context, screen: Class): Intent = + Intent(context, screen).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK) - /** Display on and unlocked: the one case the full-screen intent hands to a heads-up. */ private fun phoneInUse(context: Context): Boolean = context.getSystemService(PowerManager::class.java).isInteractive && !context.getSystemService(KeyguardManager::class.java).isKeyguardLocked + + /** The audio mode, which needs no phone permission, unlike the telecom APIs. */ + private fun inCall(context: Context): Boolean = + when (context.getSystemService(AudioManager::class.java).mode) { + AudioManager.MODE_IN_CALL, AudioManager.MODE_IN_COMMUNICATION, AudioManager.MODE_RINGTONE -> true + else -> false + } } diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/timer/service/TimerNotifications.kt b/app/src/main/java/de/jeanlucmakiola/clockula/timer/service/TimerNotifications.kt index 0c74381..86381e4 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/timer/service/TimerNotifications.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/timer/service/TimerNotifications.kt @@ -14,6 +14,7 @@ import de.jeanlucmakiola.clockula.domain.format.ClockFormat import de.jeanlucmakiola.clockula.domain.timer.TimerMode import de.jeanlucmakiola.clockula.domain.timer.TimerNotificationAction import de.jeanlucmakiola.clockula.domain.timer.TimerNotificationState +import de.jeanlucmakiola.clockula.ring.ForegroundRingScreen import de.jeanlucmakiola.clockula.timer.TimerIntents import de.jeanlucmakiola.clockula.timer.receiver.TimerActionReceiver import de.jeanlucmakiola.clockula.ui.ring.TimerRingActivity @@ -203,8 +204,7 @@ object TimerNotifications { private fun ringPendingIntent(context: Context): PendingIntent = PendingIntent.getActivity( context, TimerIntents.REQUEST_RING, - Intent(context, TimerRingActivity::class.java) - .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK), + ForegroundRingScreen.intent(context, TimerRingActivity::class.java), PENDING_INTENT_FLAGS, ) diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/timer/service/TimerService.kt b/app/src/main/java/de/jeanlucmakiola/clockula/timer/service/TimerService.kt index 6f04361..5213b1e 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/timer/service/TimerService.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/timer/service/TimerService.kt @@ -291,7 +291,14 @@ class TimerService : Service() { startForeground(TimerNotifications.timer(this, state, fullScreen)) // Where the full-screen intent would only show a heads-up. if (!state.alertOnce && sounding) { - ForegroundRingScreen.launch(this, TimerRingActivity::class.java, settings.ringOverOtherApps.first()) + // An open timer screen follows every expired timer by itself. + val screen = TimerRingActivity::class.java + ForegroundRingScreen.launch( + context = this, + screen = screen, + screenShowing = ForegroundRingScreen.isShowing(screen), + overOtherApps = settings.ringOverOtherApps.first(), + ) } } diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/settings/AlarmDefaultsScreen.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/settings/AlarmDefaultsScreen.kt index 09b4a5e..5792857 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/ui/settings/AlarmDefaultsScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/settings/AlarmDefaultsScreen.kt @@ -1,5 +1,6 @@ package de.jeanlucmakiola.clockula.ui.settings +import android.content.Context import android.content.Intent import android.net.Uri import android.provider.Settings @@ -9,7 +10,7 @@ import androidx.compose.runtime.Composable import androidx.compose.material3.Switch import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier @@ -183,35 +184,56 @@ private fun volumeButtonsText(action: VolumeButtonAction): String = stringResour ) /** - * On only while both the choice and the permission are there, so a permission - * revoked in system settings shows as off. Turning it on without the - * permission stores the choice and opens the system page that grants it; the - * switch catches up on return. + * On exactly while the choice is stored, and the choice is stored only while + * "display over other apps" is granted. Turning it on without the permission + * opens the system page that grants it and stores the choice on return if + * the user granted it there. A permission found missing (revoked, or a + * restore that brought the value back without it) clears the choice, so it + * can never come back on by itself. */ @Composable private fun RingOverOtherAppsRow(stored: Boolean, onChange: (Boolean) -> Unit) { val context = LocalContext.current - var permitted by remember { mutableStateOf(Settings.canDrawOverlays(context)) } + var awaitingGrant by rememberSaveable { mutableStateOf(false) } + // Keyed on nothing, so it runs on each return to the screen and never + // because a tap just set [awaitingGrant]; the latest values are read live. + val currentStored by rememberUpdatedState(stored) + val currentOnChange by rememberUpdatedState(onChange) LifecycleResumeEffect(Unit) { - permitted = Settings.canDrawOverlays(context) + val permitted = Settings.canDrawOverlays(context) + if (awaitingGrant) { + awaitingGrant = false + if (permitted) currentOnChange(true) + } else if (currentStored && !permitted) { + currentOnChange(false) + } onPauseOrDispose {} } - val checked = stored && permitted val toggle = { enabled: Boolean -> - onChange(enabled) - if (enabled && !permitted) { - runCatching { - context.startActivity( - Intent(Settings.ACTION_MANAGE_OVERLAY_PERMISSION, Uri.parse("package:${context.packageName}")), - ) - } + when { + !enabled -> onChange(false) + Settings.canDrawOverlays(context) -> onChange(true) + else -> awaitingGrant = openOverlaySettings(context) } } GroupedRow( title = stringResource(R.string.settings_ring_over_other_apps), summary = stringResource(R.string.settings_ring_over_other_apps_hint), position = Position.Alone, - trailing = { Switch(checked = checked, onCheckedChange = { toggle(it) }) }, - onClick = { toggle(!checked) }, + trailing = { Switch(checked = stored, onCheckedChange = { toggle(it) }) }, + onClick = { toggle(!stored) }, ) } + +/** + * The permission's own page for Clockula, else the app's details page, where + * some builds that lack the first keep the same switch. False when neither + * opens, so the choice is never left waiting on a page that never showed. + */ +private fun openOverlaySettings(context: Context): Boolean { + val pkg = Uri.parse("package:${context.packageName}") + return listOf( + Intent(Settings.ACTION_MANAGE_OVERLAY_PERMISSION, pkg), + Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS, pkg), + ).any { runCatching { context.startActivity(it) }.isSuccess } +} diff --git a/app/src/main/java/de/jeanlucmakiola/clockula/ui/settings/SettingsUiState.kt b/app/src/main/java/de/jeanlucmakiola/clockula/ui/settings/SettingsUiState.kt index 50ef7cf..7cb1cbc 100644 --- a/app/src/main/java/de/jeanlucmakiola/clockula/ui/settings/SettingsUiState.kt +++ b/app/src/main/java/de/jeanlucmakiola/clockula/ui/settings/SettingsUiState.kt @@ -17,7 +17,7 @@ internal sealed interface SettingsUiState { /** The world clock's "Display time with seconds" (#23); not a clock default. */ val worldClockSeconds: Boolean, /** The stored choice; the screen pairs it with the overlay permission. */ - val ringOverOtherApps: Boolean = false, + val ringOverOtherApps: Boolean, ) : SettingsUiState } diff --git a/app/src/test/java/de/jeanlucmakiola/clockula/domain/alarm/DirectLaunchPolicyTest.kt b/app/src/test/java/de/jeanlucmakiola/clockula/domain/alarm/DirectLaunchPolicyTest.kt new file mode 100644 index 0000000..7f9f3f6 --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/clockula/domain/alarm/DirectLaunchPolicyTest.kt @@ -0,0 +1,51 @@ +package de.jeanlucmakiola.clockula.domain.alarm + +import com.google.common.truth.Truth.assertThat +import org.junit.jupiter.api.Test + +class DirectLaunchPolicyTest { + + private fun facts( + phoneInUse: Boolean = true, + appVisible: Boolean = false, + screenShowing: Boolean = false, + overOtherApps: Boolean = false, + canDrawOverlays: Boolean = false, + inCall: Boolean = false, + ) = DirectLaunchFacts(phoneInUse, appVisible, screenShowing, overOtherApps, canDrawOverlays, inCall) + + private fun opens(facts: DirectLaunchFacts) = DirectLaunchPolicy.opensDirectly(facts) + + @Test + fun `inside the app the screen opens directly`() { + assertThat(opens(facts(appVisible = true))).isTrue() + } + + @Test + fun `over another app it stays a heads-up by default`() { + assertThat(opens(facts())).isFalse() + assertThat(opens(facts(canDrawOverlays = true))).isFalse() + } + + @Test + fun `over another app the setting needs the permission`() { + assertThat(opens(facts(overOtherApps = true))).isFalse() + assertThat(opens(facts(overOtherApps = true, canDrawOverlays = true))).isTrue() + } + + @Test + fun `a call is never covered from another app`() { + assertThat(opens(facts(overOtherApps = true, canDrawOverlays = true, inCall = true))).isFalse() + } + + @Test + fun `a locked or dark phone is left to the full-screen intent`() { + assertThat(opens(facts(phoneInUse = false, appVisible = true))).isFalse() + assertThat(opens(facts(phoneInUse = false, overOtherApps = true, canDrawOverlays = true))).isFalse() + } + + @Test + fun `a screen already showing this ring is not reopened`() { + assertThat(opens(facts(appVisible = true, screenShowing = true))).isFalse() + } +} diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 162b213..2d55a85 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -998,12 +998,15 @@ declared because a specific path would otherwise fail, silently, at 07:00. | `ACCESS_NOTIFICATION_POLICY` | issue #1's opt-in bedtime mode. Install-time, and on its own it does nothing: it lists Clockula under Special app access › Do Not Disturb, which the user grants by hand. With that grant Clockula adds one Do Not Disturb rule of its own, which always lets alarms through (§18) | | `SYSTEM_ALERT_WINDOW` | the opt-in "Ring screen over other apps". Granted by hand from the system page the setting opens. It is used only as Android's background-activity-start exemption, so a ring screen opens over the app in use where a full-screen intent would be a heads-up. Clockula draws no overlay window | -**M6 adds no permission at all.** A timer never takes over the screen (no -full-screen intent, no ring activity, no `showWhenLocked`), because its user set -it minutes ago and is in the room, so the heads-up notification, the audio, the -pill's expired state and the tab's expired row are four ways to notice and none -of them seizes the device. The foreground-service, wake-lock, vibrate and -notification set above already covers the rest. +**M6 adds no permission at all.** M6 shipped timers without a screen of their +own, on the reasoning that their user set them minutes ago and is in the room. +Issue #15 reversed that: a timer finishing on a locked phone rang behind a black +screen. An expired, sounding timer now opens `TimerRingActivity` over the lock +screen through a full-screen intent, as an alarm does, and both ring services +open their screen directly where that intent would only be a heads-up: inside +Clockula always, and over another app with the opt-in setting +(`DirectLaunchPolicy`). The foreground-service, wake-lock, vibrate and +notification set above covers the rest. Neither of the two revocable ones can silence an alarm. A denied `POST_NOTIFICATIONS` costs the user the notification and nothing else, because the