Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b630a230c8 | ||
|
|
3b14977368 | ||
|
|
d584a1a9a5 | ||
|
|
baffa9a188 | ||
|
|
d3af7d5b7c |
+3
-1
@@ -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
|
||||
|
||||
@@ -32,8 +32,8 @@ android {
|
||||
// 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
|
||||
// committed one doesn't match. See docs/RELEASING.md.
|
||||
versionCode = 1000004
|
||||
versionName = "1.0.0-beta.4"
|
||||
versionCode = 1000005
|
||||
versionName = "1.0.0-beta.5"
|
||||
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
}
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
@@ -74,6 +74,17 @@ 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. 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
|
||||
* 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) ---
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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.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,
|
||||
)
|
||||
|
||||
|
||||
@@ -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(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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