Author SHA1 Message Date
makiolaj b630a230c8 chore(release): 1.0.0-beta.5
Beta — Codeberg pre-release / detect (push) Successful in 6s
Beta — Codeberg pre-release / beta (push) Successful in 17m6s
2026-10-28 12:20:59 +01:00
makiolaj 3b14977368 Merge branch 'fix/ring-screen-in-app' into release/v1.0.0 2026-10-28 12:20:59 +01:00
makiolaj d584a1a9a5 fix(ring): open the ring screen directly only where the policy allows
The visibility check counted the ring service's own foreground service as
a visible window, so every ring opened its screen directly: over any app
without the setting, and on a locked phone on top of the full-screen
intent. Started activities are now tracked through lifecycle callbacks.

The decision moves into DirectLaunchPolicy with JVM tests. It needs a
phone in use, leaves a screen already showing this ring alone so a
restart no longer resets a dismiss challenge, and never covers a call
from another app.

The switch stores the choice only once the overlay permission is granted
and clears it when the permission is gone, so it can't stick or come
back on by itself. It falls back to the app details page where the
permission page is missing. One shared intent opens the ring screens,
and the architecture notes no longer say a timer never takes the screen.
2026-10-28 12:11:01 +01:00
makiolaj baffa9a188 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.
2026-10-28 12:01:43 +01:00
makiolaj d3af7d5b7c fix(ring): open the ring screen when an alarm or timer goes off in the app
A full-screen intent opens its activity only on a locked phone or a dark
display. While the user is in Clockula, SystemUI shows it as a heads-up
and the ring screen never comes up. Both ring services now start their
screen directly when the app has a visible window, which Android allows
from the background. Anywhere else the full-screen intent still decides.
2026-10-28 11:55:12 +01:00
20 changed files with 341 additions and 18 deletions

No files matched your search

+3 -1
View File
@@ -47,7 +47,9 @@ The first release of Clockula: alarms, timers, a stopwatch and a world clock.
default sound instead. default sound instead.
- A ring screen over the lock screen with snooze and dismiss. The dismiss - 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 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 - Choose what the volume buttons do while an alarm rings: change the volume
(as before), snooze, dismiss or nothing. Dismiss still respects the dismiss (as before), snooze, dismiss or nothing. Dismiss still respects the dismiss
challenge by snoozing instead, and a snooze press with no snoozes left challenge by snoozing instead, and a snooze press with no snoozes left
+2 -2
View File
@@ -32,8 +32,8 @@ android {
// versionCode is derived from versionName by scripts/version_info.sh // versionCode is derived from versionName by scripts/version_info.sh
// (1.0.0-beta.1 -> 1000001, 1.0.0 -> 1000099), and CI fails if the // (1.0.0-beta.1 -> 1000001, 1.0.0 -> 1000099), and CI fails if the
// committed one doesn't match. See docs/RELEASING.md. // committed one doesn't match. See docs/RELEASING.md.
versionCode = 1000004 versionCode = 1000005
versionName = "1.0.0-beta.4" versionName = "1.0.0-beta.5"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
} }
+5
View File
@@ -24,6 +24,11 @@
heads-up notification that still sounds — never to silence. --> heads-up notification that still sounds — never to silence. -->
<uses-permission android:name="android.permission.USE_FULL_SCREEN_INTENT" /> <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 <!-- The ring notification. Denied, the alarm still sounds: the audio belongs
to the foreground service, not to the notification. --> to the foreground service, not to the notification. -->
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" /> <uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
@@ -8,6 +8,7 @@ import de.jeanlucmakiola.clockula.alarm.ring.UpcomingNotifications
import de.jeanlucmakiola.clockula.bedtime.BedtimeEngine import de.jeanlucmakiola.clockula.bedtime.BedtimeEngine
import de.jeanlucmakiola.clockula.bedtime.android.AndroidBedtimeNotifier import de.jeanlucmakiola.clockula.bedtime.android.AndroidBedtimeNotifier
import de.jeanlucmakiola.clockula.data.prefs.SettingsPrefs import de.jeanlucmakiola.clockula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.clockula.ring.ForegroundRingScreen
import de.jeanlucmakiola.clockula.stopwatch.StopwatchEngine import de.jeanlucmakiola.clockula.stopwatch.StopwatchEngine
import de.jeanlucmakiola.clockula.stopwatch.service.StopwatchNotifications import de.jeanlucmakiola.clockula.stopwatch.service.StopwatchNotifications
import de.jeanlucmakiola.clockula.timer.TimerEngine import de.jeanlucmakiola.clockula.timer.TimerEngine
@@ -69,6 +70,7 @@ class ClockulaApp : Application() {
), ),
) )
ForegroundRingScreen.track(this)
RingNotifications.createChannels(this) RingNotifications.createChannels(this)
UpcomingNotifications.createChannel(this) UpcomingNotifications.createChannel(this)
TimerNotifications.createChannel(this) TimerNotifications.createChannel(this)
@@ -14,17 +14,21 @@ import dagger.hilt.android.AndroidEntryPoint
import de.jeanlucmakiola.clockula.alarm.AlarmCapabilities import de.jeanlucmakiola.clockula.alarm.AlarmCapabilities
import de.jeanlucmakiola.clockula.alarm.AlarmEngine import de.jeanlucmakiola.clockula.alarm.AlarmEngine
import de.jeanlucmakiola.clockula.alarm.AlarmIntents 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.RingPresentationPolicy
import de.jeanlucmakiola.clockula.domain.alarm.RingSession import de.jeanlucmakiola.clockula.domain.alarm.RingSession
import de.jeanlucmakiola.clockula.domain.ring.RingFallbackPolicy import de.jeanlucmakiola.clockula.domain.ring.RingFallbackPolicy
import de.jeanlucmakiola.clockula.domain.ring.SilenceAfter import de.jeanlucmakiola.clockula.domain.ring.SilenceAfter
import de.jeanlucmakiola.clockula.ring.ForegroundRingScreen
import de.jeanlucmakiola.clockula.ring.RingAudioPlayer import de.jeanlucmakiola.clockula.ring.RingAudioPlayer
import de.jeanlucmakiola.clockula.ring.RingVibrator import de.jeanlucmakiola.clockula.ring.RingVibrator
import de.jeanlucmakiola.clockula.ui.ring.AlarmRingActivity
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import javax.inject.Inject import javax.inject.Inject
@@ -46,6 +50,9 @@ class AlarmRingService : Service() {
@Inject @Inject
lateinit var capabilities: AlarmCapabilities lateinit var capabilities: AlarmCapabilities
@Inject
lateinit var settings: SettingsPrefs
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate) private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
private val audio by lazy { RingAudioPlayer(this) } private val audio by lazy { RingAudioPlayer(this) }
@@ -54,6 +61,9 @@ class AlarmRingService : Service() {
private var wakeLock: PowerManager.WakeLock? = null private var wakeLock: PowerManager.WakeLock? = null
private var ringing: Job? = 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. */ /** The notification last posted, put back as it was when the user swipes it away. */
private var posted: Notification? = null private var posted: Notification? = null
@@ -93,6 +103,7 @@ class AlarmRingService : Service() {
return@launch return@launch
} }
ring(session) ring(session)
openScreen(session)
} }
// Re-delivered rather than dropped: a killed ring is resumed with the // Re-delivered rather than dropped: a killed ring is resumed with the
// same intent, and the session is re-read from storage anyway. // 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] * The placeholder the service goes foreground with immediately; [ring]
* replaces it with the alarm's own notification as soon as the session is * replaces it with the alarm's own notification as soon as the session is
@@ -14,6 +14,7 @@ import de.jeanlucmakiola.clockula.domain.Alarm
import de.jeanlucmakiola.clockula.domain.DismissChallenge import de.jeanlucmakiola.clockula.domain.DismissChallenge
import de.jeanlucmakiola.clockula.domain.alarm.RingPresentation import de.jeanlucmakiola.clockula.domain.alarm.RingPresentation
import de.jeanlucmakiola.clockula.domain.alarm.RingSession import de.jeanlucmakiola.clockula.domain.alarm.RingSession
import de.jeanlucmakiola.clockula.ring.ForegroundRingScreen
import de.jeanlucmakiola.clockula.ui.ring.AlarmRingActivity import de.jeanlucmakiola.clockula.ui.ring.AlarmRingActivity
import java.time.Instant as JavaInstant import java.time.Instant as JavaInstant
import java.time.LocalTime import java.time.LocalTime
@@ -190,9 +191,7 @@ object RingNotifications {
private fun showPendingIntent(context: Context): PendingIntent = PendingIntent.getActivity( private fun showPendingIntent(context: Context): PendingIntent = PendingIntent.getActivity(
context, context,
AlarmIntents.REQUEST_SHOW, AlarmIntents.REQUEST_SHOW,
Intent(context, AlarmRingActivity::class.java).apply { ForegroundRingScreen.intent(context, AlarmRingActivity::class.java),
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
},
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
) )
@@ -74,6 +74,17 @@ object ClockPrefs {
*/ */
val worldClockSeconds: Pref<Boolean> = booleanPref("world_clock_show_seconds", false) 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. 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<Boolean> = booleanPref("ring_screen_over_other_apps", false)
/** /**
* The World clock tab's temporary local-time row while the device is in a * 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 * 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) 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) --- // --- timer presets (M6) ---
/** /**
@@ -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 } enum class ScheduleMode { EXACT_ALARM_CLOCK, INEXACT_FALLBACK }
object SchedulingPolicy { object SchedulingPolicy {
@@ -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<Activity>()
/**
* 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<out Activity>): 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<out Activity>, 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<out Activity>): 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
}
}
@@ -14,6 +14,7 @@ import de.jeanlucmakiola.clockula.domain.format.ClockFormat
import de.jeanlucmakiola.clockula.domain.timer.TimerMode import de.jeanlucmakiola.clockula.domain.timer.TimerMode
import de.jeanlucmakiola.clockula.domain.timer.TimerNotificationAction import de.jeanlucmakiola.clockula.domain.timer.TimerNotificationAction
import de.jeanlucmakiola.clockula.domain.timer.TimerNotificationState import de.jeanlucmakiola.clockula.domain.timer.TimerNotificationState
import de.jeanlucmakiola.clockula.ring.ForegroundRingScreen
import de.jeanlucmakiola.clockula.timer.TimerIntents import de.jeanlucmakiola.clockula.timer.TimerIntents
import de.jeanlucmakiola.clockula.timer.receiver.TimerActionReceiver import de.jeanlucmakiola.clockula.timer.receiver.TimerActionReceiver
import de.jeanlucmakiola.clockula.ui.ring.TimerRingActivity import de.jeanlucmakiola.clockula.ui.ring.TimerRingActivity
@@ -203,8 +204,7 @@ object TimerNotifications {
private fun ringPendingIntent(context: Context): PendingIntent = PendingIntent.getActivity( private fun ringPendingIntent(context: Context): PendingIntent = PendingIntent.getActivity(
context, context,
TimerIntents.REQUEST_RING, TimerIntents.REQUEST_RING,
Intent(context, TimerRingActivity::class.java) ForegroundRingScreen.intent(context, TimerRingActivity::class.java),
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK),
PENDING_INTENT_FLAGS, PENDING_INTENT_FLAGS,
) )
@@ -20,12 +20,14 @@ import de.jeanlucmakiola.clockula.domain.timer.TimerMode
import de.jeanlucmakiola.clockula.domain.timer.TimerRing import de.jeanlucmakiola.clockula.domain.timer.TimerRing
import de.jeanlucmakiola.clockula.domain.timer.TimerRingDecision import de.jeanlucmakiola.clockula.domain.timer.TimerRingDecision
import de.jeanlucmakiola.clockula.domain.timer.TimerSettings import de.jeanlucmakiola.clockula.domain.timer.TimerSettings
import de.jeanlucmakiola.clockula.ring.ForegroundRingScreen
import de.jeanlucmakiola.clockula.ring.RingAudioPlayer import de.jeanlucmakiola.clockula.ring.RingAudioPlayer
import de.jeanlucmakiola.clockula.ring.RingVibrator import de.jeanlucmakiola.clockula.ring.RingVibrator
import de.jeanlucmakiola.clockula.timer.AlarmRingStatus import de.jeanlucmakiola.clockula.timer.AlarmRingStatus
import de.jeanlucmakiola.clockula.timer.TimerEngine import de.jeanlucmakiola.clockula.timer.TimerEngine
import de.jeanlucmakiola.clockula.timer.TimerIntents import de.jeanlucmakiola.clockula.timer.TimerIntents
import de.jeanlucmakiola.clockula.timer.TimerServiceState import de.jeanlucmakiola.clockula.timer.TimerServiceState
import de.jeanlucmakiola.clockula.ui.ring.TimerRingActivity
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
@@ -35,6 +37,7 @@ import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.merge import kotlinx.coroutines.flow.merge
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlin.time.Duration 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 var fullScreen = false
if (!state.alertOnce) { if (!state.alertOnce) {
alreadyAlerted = true alreadyAlerted = true
@@ -286,6 +289,17 @@ class TimerService : Service() {
fullScreen = sounding && capabilities.snapshot().canUseFullScreenIntent fullScreen = sounding && capabilities.snapshot().canUseFullScreenIntent
} }
startForeground(TimerNotifications.timer(this, state, fullScreen)) 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(),
)
}
} }
/** /**
@@ -1,15 +1,23 @@
package de.jeanlucmakiola.clockula.ui.settings 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.Spacer
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.material3.Switch
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.LifecycleResumeEffect
import de.jeanlucmakiola.clockula.R import de.jeanlucmakiola.clockula.R
import de.jeanlucmakiola.clockula.domain.alarm.VolumeButtonAction import de.jeanlucmakiola.clockula.domain.alarm.VolumeButtonAction
import de.jeanlucmakiola.clockula.ui.alarms.DismissChallengePicker 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 * 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. * The ringtone picker itself is ViewModel state, drawn by the hub.
*/ */
@Composable @Composable
@@ -107,6 +116,8 @@ internal fun AlarmDefaultsScreen(
onClick = { openPicker = OpenAlarmPicker.VOLUME_BUTTONS }, onClick = { openPicker = OpenAlarmPicker.VOLUME_BUTTONS },
) )
SettingsHint(stringResource(R.string.settings_volume_buttons_hint)) SettingsHint(stringResource(R.string.settings_volume_buttons_hint))
Spacer(Modifier.height(24.dp))
RingOverOtherAppsRow(state.ringOverOtherApps, viewModel::onRingOverOtherAppsChanged)
} }
when (openPicker) { when (openPicker) {
@@ -171,3 +182,58 @@ private fun volumeButtonsText(action: VolumeButtonAction): String = stringResour
VolumeButtonAction.NOTHING -> R.string.volume_buttons_nothing 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 }
}
@@ -16,6 +16,8 @@ internal sealed interface SettingsUiState {
val selfCheckProblemCount: Int? = null, val selfCheckProblemCount: Int? = null,
/** The world clock's "Display time with seconds" (#23); not a clock default. */ /** The world clock's "Display time with seconds" (#23); not a clock default. */
val worldClockSeconds: Boolean, val worldClockSeconds: Boolean,
/** The stored choice; the screen pairs it with the overlay permission. */
val ringOverOtherApps: Boolean,
) : SettingsUiState ) : SettingsUiState
} }
@@ -73,8 +73,9 @@ class SettingsViewModel @Inject internal constructor(
settings.defaults, settings.defaults,
selfCheckProblemCount, selfCheckProblemCount,
settings.worldClockSeconds, settings.worldClockSeconds,
) { appearance, defaults, problemCount, worldClockSeconds -> settings.ringOverOtherApps,
SettingsUiState.Loaded(appearance, defaults, problemCount, worldClockSeconds) ) { appearance, defaults, problemCount, worldClockSeconds, ringOverOtherApps ->
SettingsUiState.Loaded(appearance, defaults, problemCount, worldClockSeconds, ringOverOtherApps)
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(SUBSCRIPTION_GRACE_MS), SettingsUiState.Loading) }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(SUBSCRIPTION_GRACE_MS), SettingsUiState.Loading)
/** /**
@@ -253,6 +254,9 @@ class SettingsViewModel @Inject internal constructor(
fun onWorldClockSecondsChanged(enabled: Boolean) = fun onWorldClockSecondsChanged(enabled: Boolean) =
viewModelScope.launch { settings.setWorldClockSeconds(enabled) } viewModelScope.launch { settings.setWorldClockSeconds(enabled) }
fun onRingOverOtherAppsChanged(enabled: Boolean) =
viewModelScope.launch { settings.setRingOverOtherApps(enabled) }
// --- the home time-zone picker --- // --- the home time-zone picker ---
fun onOpenHomeZonePicker() { fun onOpenHomeZonePicker() {
+2
View File
@@ -344,6 +344,8 @@
<!-- #18: what the volume buttons do while an alarm rings --> <!-- #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">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_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_control_volume">Change the volume</string>
<string name="volume_buttons_snooze">Snooze</string> <string name="volume_buttons_snooze">Snooze</string>
<string name="volume_buttons_dismiss">Dismiss</string> <string name="volume_buttons_dismiss">Dismiss</string>
@@ -285,6 +285,9 @@ class ManifestRulesTest {
"android.permission.SCHEDULE_EXACT_ALARM", "android.permission.SCHEDULE_EXACT_ALARM",
"android.permission.RECEIVE_BOOT_COMPLETED", "android.permission.RECEIVE_BOOT_COMPLETED",
"android.permission.USE_FULL_SCREEN_INTENT", "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", "android.permission.POST_NOTIFICATIONS",
// Already present for the timer's Live Update chip; not added by // 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 // M10, which — per docs/PLAN.md's amended §1 #2 — adds no permission
@@ -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()
}
}
+10 -6
View File
@@ -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 | | `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 | | `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) | | `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 **M6 adds no permission at all.** M6 shipped timers without a screen of their
full-screen intent, no ring activity, no `showWhenLocked`), because its user set own, on the reasoning that their user set them minutes ago and is in the room.
it minutes ago and is in the room, so the heads-up notification, the audio, the Issue #15 reversed that: a timer finishing on a locked phone rang behind a black
pill's expired state and the tab's expired row are four ways to notice and none screen. An expired, sounding timer now opens `TimerRingActivity` over the lock
of them seizes the device. The foreground-service, wake-lock, vibrate and screen through a full-screen intent, as an alarm does, and both ring services
notification set above already covers the rest. 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 Neither of the two revocable ones can silence an alarm. A denied
`POST_NOTIFICATIONS` costs the user the notification and nothing else, because the `POST_NOTIFICATIONS` costs the user the notification and nothing else, because the
+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 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 Disturb mode of its own and turns it on and off on your schedule. It does not
read your notifications. 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 - `ACCESS_NETWORK_STATE`: added by the Android library that updates the
home-screen widgets (WorkManager). It lets an app see whether a network 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 connection exists. It does not let it use one, and without `INTERNET` no