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:
1 parent
d3af7d5b7c
commit
baffa9a188
14 files changed
+122
-20
No files matched your search
+2
-1
@@ -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
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user