diff --git a/CHANGELOG.md b/CHANGELOG.md
index a7580d0..9538fc5 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -47,7 +47,9 @@ 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, 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/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 1b18bd8..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
@@ -14,17 +14,21 @@ 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
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
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
+import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import javax.inject.Inject
@@ -46,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) }
@@ -54,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
@@ -93,6 +103,7 @@ class AlarmRingService : Service() {
return@launch
}
ring(session)
+ 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.
@@ -123,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 daa9aef..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
@@ -74,6 +74,17 @@ 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. 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)
+
/**
* 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/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
new file mode 100644
index 0000000..6ea5202
--- /dev/null
+++ b/app/src/main/java/de/jeanlucmakiola/clockula/ring/ForegroundRingScreen.kt
@@ -0,0 +1,90 @@
+package de.jeanlucmakiola.clockula.ring
+
+import android.app.Activity
+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
+ * 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 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". When either
+ * applies is [DirectLaunchPolicy]'s to say; this object only reads the facts.
+ */
+object ForegroundRingScreen {
+
+ /** 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)) }
+ }
+
+ /** 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)
+
+ 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 7ed553a..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
@@ -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
@@ -35,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
@@ -272,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
@@ -286,6 +289,17 @@ class TimerService : Service() {
fullScreen = sounding && capabilities.snapshot().canUseFullScreenIntent
}
startForeground(TimerNotifications.timer(this, state, fullScreen))
+ // Where the full-screen intent would only show a heads-up.
+ if (!state.alertOnce && sounding) {
+ // 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 7029d2d..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,15 +1,23 @@
package de.jeanlucmakiola.clockula.ui.settings
+import android.content.Context
+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.rememberUpdatedState
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 +40,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 +116,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 +182,58 @@ private fun volumeButtonsText(action: VolumeButtonAction): String = stringResour
VolumeButtonAction.NOTHING -> R.string.volume_buttons_nothing
},
)
+
+/**
+ * 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 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) {
+ val permitted = Settings.canDrawOverlays(context)
+ if (awaitingGrant) {
+ awaitingGrant = false
+ if (permitted) currentOnChange(true)
+ } else if (currentStored && !permitted) {
+ currentOnChange(false)
+ }
+ onPauseOrDispose {}
+ }
+ val toggle = { enabled: Boolean ->
+ 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 = 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 80a6372..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
@@ -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,
) : 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/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 9e3b983..2d55a85 100644
--- a/docs/ARCHITECTURE.md
+++ b/docs/ARCHITECTURE.md
@@ -996,13 +996,17 @@ 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
-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
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