From baffa9a188a608cd3b68bc2341941be0fe2311fe Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Wed, 28 Oct 2026 12:01:43 +0100 Subject: [PATCH] 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