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.
This commit is contained in:
makiolaj committed 2026-10-28 12:01:43 +01:00
1 parent d3af7d5b7c
commit baffa9a188
14 files changed
+122 -20

No files matched your search

+2 -1
View File
@@ -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
+5
View File
@@ -24,6 +24,11 @@
heads-up notification that still sounds — never to silence. -->
<uses-permission android:name="android.permission.USE_FULL_SCREEN_INTENT" />
<!-- The opt-in "Ring screen over other apps": while it is granted, a ring
screen opens over the app in use instead of a heads-up. Not granted
at install; turning the setting on sends the user to grant it. -->
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW" />
<!-- The ring notification. Denied, the alarm still sounds: the audio belongs
to the foreground service, not to the notification. -->
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
@@ -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) {
@@ -74,6 +74,14 @@ object ClockPrefs {
*/
val worldClockSeconds: Pref<Boolean> = 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<Boolean> = 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
@@ -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<Boolean> = store.flow(ClockPrefs.ringOverOtherApps)
suspend fun setRingOverOtherApps(enabled: Boolean) = store.set(ClockPrefs.ringOverOtherApps, enabled)
// --- timer presets (M6) ---
/**
@@ -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<out Activity>) {
if (!appIsVisible()) return
fun launch(context: Context, screen: Class<out Activity>, 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
}
@@ -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())
}
}
/**
@@ -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) },
)
}
@@ -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
}
@@ -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() {
+2
View File
@@ -344,6 +344,8 @@
<!-- #18: what the volume buttons do while an alarm rings -->
<string name="settings_volume_buttons">Volume buttons while ringing</string>
<string name="settings_volume_buttons_hint">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.</string>
<string name="settings_ring_over_other_apps">Ring screen over other apps</string>
<string name="settings_ring_over_other_apps_hint">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.</string>
<string name="volume_buttons_control_volume">Change the volume</string>
<string name="volume_buttons_snooze">Snooze</string>
<string name="volume_buttons_dismiss">Dismiss</string>
@@ -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
+1
View File
@@ -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
+3
View File
@@ -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