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

+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