feat(alarm): the ringing service, and never degrading to silence
A systemExempted foreground service holds the ring: audio focus, USAGE_ALARM, the volume ramp, vibration, and the ringtone URI. It goes foreground synchronously before it reads anything from the database, because the platform kills a service that reaches startForeground late — including on the path where there turns out to be no session to ring. The chain that matters is the one that never ends in silence. A full-screen intent when canUseFullScreenIntent() allows it; a high-priority heads-up notification that still rings when it does not; the device's default alarm ringtone when the configured URI will not open; and forced vibration when no audio source will play at all. Preparing a MediaPlayer is done off the main thread — a synchronous prepare at the moment the alarm fires is an ANR — and a player is released rather than orphaned on every failure path, so a dismissal cannot be outlived by the ring it cancelled. The ring screen here is plumbing and a placeholder: window flags to show over the lockscreen and turn the screen on. Its UI is M4. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -3,14 +3,44 @@
|
|||||||
xmlns:tools="http://schemas.android.com/tools">
|
xmlns:tools="http://schemas.android.com/tools">
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
No permissions yet, deliberately. Clockula's permission set is the alarm
|
Clockula's permission set is the alarm engine's, and nothing more. Each
|
||||||
engine's (USE_EXACT_ALARM, POST_NOTIFICATIONS, RECEIVE_BOOT_COMPLETED,
|
one is here because a specific path would otherwise fail silently — see
|
||||||
USE_FULL_SCREEN_INTENT, FOREGROUND_SERVICE*) and it is declared in M3
|
docs/ARCHITECTURE.md §9 and docs/PLAN.md §4.
|
||||||
alongside the receivers and the ringing service that need it — so the
|
|
||||||
manifest never claims a capability the code cannot yet honour. See
|
|
||||||
docs/PLAN.md §4.
|
|
||||||
-->
|
-->
|
||||||
|
|
||||||
|
<!-- The exact-alarm ladder. USE_EXACT_ALARM is install-granted on 33+ for an
|
||||||
|
app whose core function is alarms; SCHEDULE_EXACT_ALARM covers 31–32,
|
||||||
|
where it is pre-granted, and is bounded there so the two never overlap.
|
||||||
|
Below 31 an exact alarm needs no permission at all. -->
|
||||||
|
<uses-permission android:name="android.permission.USE_EXACT_ALARM" />
|
||||||
|
<uses-permission
|
||||||
|
android:name="android.permission.SCHEDULE_EXACT_ALARM"
|
||||||
|
android:maxSdkVersion="32" />
|
||||||
|
|
||||||
|
<!-- Re-register the next alarm after a reboot: AlarmManager keeps nothing. -->
|
||||||
|
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
|
||||||
|
|
||||||
|
<!-- The ring screen over the lock screen. Denied, the ring degrades to a
|
||||||
|
heads-up notification that still sounds — never to silence. -->
|
||||||
|
<uses-permission android:name="android.permission.USE_FULL_SCREEN_INTENT" />
|
||||||
|
|
||||||
|
<!-- 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" />
|
||||||
|
|
||||||
|
<!-- The ringing service. systemExempted is Android's own answer for an app
|
||||||
|
holding an exact-alarm permission that continues an alarm in the
|
||||||
|
background, including a haptics-only one. -->
|
||||||
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||||
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SYSTEM_EXEMPTED" />
|
||||||
|
|
||||||
|
<!-- Keep the CPU awake for the length of a ring; the screen is the window's
|
||||||
|
business, not this one's. -->
|
||||||
|
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
||||||
|
|
||||||
|
<!-- The alarm vibrates, and vibrates on its own when no audio source opens. -->
|
||||||
|
<uses-permission android:name="android.permission.VIBRATE" />
|
||||||
|
|
||||||
<application
|
<application
|
||||||
android:name=".ClockulaApp"
|
android:name=".ClockulaApp"
|
||||||
android:allowBackup="true"
|
android:allowBackup="true"
|
||||||
@@ -42,6 +72,53 @@
|
|||||||
android:excludeFromRecents="true"
|
android:excludeFromRecents="true"
|
||||||
android:launchMode="singleTask" />
|
android:launchMode="singleTask" />
|
||||||
|
|
||||||
|
<!-- The ring screen. Shown over the lock screen with the display turned
|
||||||
|
on; excluded from recents so a dismissed alarm leaves nothing
|
||||||
|
behind. M4 replaces its contents, not its declaration. -->
|
||||||
|
<activity
|
||||||
|
android:name=".ui.ring.AlarmRingActivity"
|
||||||
|
android:exported="false"
|
||||||
|
android:excludeFromRecents="true"
|
||||||
|
android:launchMode="singleTask"
|
||||||
|
android:showOnLockScreen="true"
|
||||||
|
android:showWhenLocked="true"
|
||||||
|
android:taskAffinity=""
|
||||||
|
android:turnScreenOn="true" />
|
||||||
|
|
||||||
|
<!-- The ring itself. systemExempted per D17: not mediaPlayback (an alarm
|
||||||
|
is not the user's media session) and emphatically not shortService,
|
||||||
|
which caps at ~3 minutes against a 10-minute ring window. -->
|
||||||
|
<service
|
||||||
|
android:name=".alarm.ring.AlarmRingService"
|
||||||
|
android:exported="false"
|
||||||
|
android:foregroundServiceType="systemExempted" />
|
||||||
|
|
||||||
|
<!-- The two AlarmManager slots arriving. Unexported: a PendingIntent the
|
||||||
|
app created is delivered regardless, and nothing else may fake a fire. -->
|
||||||
|
<receiver
|
||||||
|
android:name=".alarm.receiver.AlarmFireReceiver"
|
||||||
|
android:exported="false" />
|
||||||
|
|
||||||
|
<!-- The notification's Snooze and Dismiss buttons. -->
|
||||||
|
<receiver
|
||||||
|
android:name=".alarm.receiver.AlarmActionReceiver"
|
||||||
|
android:exported="false" />
|
||||||
|
|
||||||
|
<!-- Protected system broadcasts are delivered to unexported receivers —
|
||||||
|
this is how androidx.work declares its own RescheduleReceiver.
|
||||||
|
LOCKED_BOOT_COMPLETED is deliberately absent: the database and the
|
||||||
|
preferences live in credential-encrypted storage. -->
|
||||||
|
<receiver
|
||||||
|
android:name=".alarm.receiver.SystemEventReceiver"
|
||||||
|
android:exported="false">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.BOOT_COMPLETED" />
|
||||||
|
<action android:name="android.intent.action.TIME_SET" />
|
||||||
|
<action android:name="android.intent.action.TIMEZONE_CHANGED" />
|
||||||
|
<action android:name="android.intent.action.MY_PACKAGE_REPLACED" />
|
||||||
|
</intent-filter>
|
||||||
|
</receiver>
|
||||||
|
|
||||||
<!-- Persists the per-app language on API < 33, where the platform
|
<!-- Persists the per-app language on API < 33, where the platform
|
||||||
per-app-languages API is unavailable. On 33+ this is a no-op. -->
|
per-app-languages API is unavailable. On 33+ this is a no-op. -->
|
||||||
<service
|
<service
|
||||||
|
|||||||
@@ -2,21 +2,35 @@ package de.jeanlucmakiola.clockula
|
|||||||
|
|
||||||
import android.app.Application
|
import android.app.Application
|
||||||
import dagger.hilt.android.HiltAndroidApp
|
import dagger.hilt.android.HiltAndroidApp
|
||||||
|
import de.jeanlucmakiola.clockula.alarm.AlarmEngine
|
||||||
|
import de.jeanlucmakiola.clockula.alarm.ring.RingNotifications
|
||||||
import de.jeanlucmakiola.floret.crash.CrashConfig
|
import de.jeanlucmakiola.floret.crash.CrashConfig
|
||||||
import de.jeanlucmakiola.floret.crash.CrashReporter
|
import de.jeanlucmakiola.floret.crash.CrashReporter
|
||||||
|
import de.jeanlucmakiola.floret.di.ApplicationScope
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Application entry point. Registered as android:name=".ClockulaApp".
|
* Application entry point. Registered as android:name=".ClockulaApp".
|
||||||
*
|
*
|
||||||
* For now this is Hilt init plus crash capture. From M3 it also owns the launch-
|
* Hilt init, crash capture, and the launch-time alarm re-sync: an alarm's next
|
||||||
* time alarm re-sync: an alarm's next fire time is resolved against the current
|
* fire time is resolved against the current zone, never stored absolutely
|
||||||
* zone, never stored absolutely (docs/PLAN.md §4), so anything that could have
|
* (docs/PLAN.md §4), so anything that could have moved while the app was closed
|
||||||
* moved while the app was closed has to be re-resolved before the user can see
|
* is re-resolved before the user can see a stale one. The same pass closes the
|
||||||
* a stale one.
|
* reboot's blind spot — a `BOOT_COMPLETED` the system never delivered is still
|
||||||
|
* repaired here, because the boot gate is persisted rather than in memory.
|
||||||
*/
|
*/
|
||||||
@HiltAndroidApp
|
@HiltAndroidApp
|
||||||
class ClockulaApp : Application() {
|
class ClockulaApp : Application() {
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
lateinit var engine: AlarmEngine
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
@ApplicationScope
|
||||||
|
lateinit var scope: CoroutineScope
|
||||||
|
|
||||||
override fun onCreate() {
|
override fun onCreate() {
|
||||||
super.onCreate()
|
super.onCreate()
|
||||||
// Install first thing so startup crashes are captured too (privacy-
|
// Install first thing so startup crashes are captured too (privacy-
|
||||||
@@ -30,5 +44,13 @@ class ClockulaApp : Application() {
|
|||||||
issueTitle = getString(R.string.crash_report_issue_title),
|
issueTitle = getString(R.string.crash_report_issue_title),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
RingNotifications.createChannels(this)
|
||||||
|
|
||||||
|
// The same three steps as a BOOT_COMPLETED, and taken through the engine
|
||||||
|
// rather than one by one so the whole pass runs under the engine's lock:
|
||||||
|
// a cold start caused by the fire broadcast must not interleave its
|
||||||
|
// read-modify-write over `alarm_states` with the fire's own.
|
||||||
|
scope.launch { engine.onBootCompleted() }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
package de.jeanlucmakiola.clockula.alarm.android
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.pm.PackageManager
|
||||||
|
import android.os.Build
|
||||||
|
import androidx.core.app.NotificationManagerCompat
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
|
import de.jeanlucmakiola.clockula.alarm.AlarmNotifier
|
||||||
|
import de.jeanlucmakiola.clockula.alarm.ring.RingNotifications
|
||||||
|
import de.jeanlucmakiola.clockula.domain.Alarm
|
||||||
|
import kotlin.time.Instant
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The snoozed notification — the one surface the engine posts directly. A denied
|
||||||
|
* notification permission costs the user this line and nothing else: the snooze
|
||||||
|
* itself is a row in `alarm_states` and an AlarmManager registration.
|
||||||
|
*/
|
||||||
|
@Singleton
|
||||||
|
class AndroidAlarmNotifier @Inject constructor(
|
||||||
|
@param:ApplicationContext private val context: Context,
|
||||||
|
) : AlarmNotifier {
|
||||||
|
|
||||||
|
private val notifications = NotificationManagerCompat.from(context)
|
||||||
|
|
||||||
|
override fun showSnoozed(alarm: Alarm, until: Instant) {
|
||||||
|
// POST_NOTIFICATIONS is a runtime permission from API 33; below it, it is
|
||||||
|
// not one. Denied, the user loses this line and keeps the alarm.
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU &&
|
||||||
|
ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) !=
|
||||||
|
PackageManager.PERMISSION_GRANTED
|
||||||
|
) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
runCatching {
|
||||||
|
notifications.notify(
|
||||||
|
RingNotifications.snoozedNotificationId(alarm.id),
|
||||||
|
RingNotifications.snoozed(context, alarm, until),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun clearSnoozed(alarmId: Long) {
|
||||||
|
notifications.cancel(RingNotifications.snoozedNotificationId(alarmId))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
package de.jeanlucmakiola.clockula.alarm.android
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
|
import de.jeanlucmakiola.clockula.alarm.RingCoordinator
|
||||||
|
import de.jeanlucmakiola.clockula.alarm.ring.AlarmRingService
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Starts and stops [AlarmRingService], and holds which alarm is ringing for the
|
||||||
|
* ring screen to collect. The StateFlow is seeded null in a fresh process: the
|
||||||
|
* persisted `alarm_states` row is the truth a restarted process reads, not this.
|
||||||
|
*/
|
||||||
|
@Singleton
|
||||||
|
class ServiceRingCoordinator @Inject constructor(
|
||||||
|
@param:ApplicationContext private val context: Context,
|
||||||
|
) : RingCoordinator {
|
||||||
|
|
||||||
|
private val state = MutableStateFlow<Long?>(null)
|
||||||
|
|
||||||
|
override val ringing: StateFlow<Long?> = state.asStateFlow()
|
||||||
|
|
||||||
|
override fun startRinging(alarmId: Long) {
|
||||||
|
state.value = alarmId
|
||||||
|
AlarmRingService.start(context)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun stopRinging(alarmId: Long) {
|
||||||
|
if (state.value == alarmId) state.value = null
|
||||||
|
AlarmRingService.stop(context)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
package de.jeanlucmakiola.clockula.alarm.ring
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.media.AudioAttributes
|
||||||
|
import android.media.AudioFocusRequest
|
||||||
|
import android.media.AudioManager
|
||||||
|
import android.media.MediaPlayer
|
||||||
|
import android.media.RingtoneManager
|
||||||
|
import android.net.Uri
|
||||||
|
import de.jeanlucmakiola.clockula.domain.alarm.AlarmRing
|
||||||
|
import de.jeanlucmakiola.clockula.domain.alarm.VolumeRamp
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.isActive
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import kotlin.time.Duration
|
||||||
|
import kotlin.time.Duration.Companion.seconds
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `MediaPlayer` over `USAGE_ALARM`, plus the ramp loop. The ramp is stepped into
|
||||||
|
* [MediaPlayer.setVolume] and **never** into `AudioManager.setStreamVolume`,
|
||||||
|
* which would edit the user's own alarm volume and leave it edited.
|
||||||
|
*
|
||||||
|
* [start] returns false when no audio source could be opened at all — which is
|
||||||
|
* what turns forced vibration on (`RingFallbackPolicy`), so the alarm is never
|
||||||
|
* silent.
|
||||||
|
*/
|
||||||
|
class AlarmAudioPlayer(private val context: Context) {
|
||||||
|
|
||||||
|
private val audioManager: AudioManager = context.getSystemService(AudioManager::class.java)
|
||||||
|
|
||||||
|
private val attributes: AudioAttributes = AudioAttributes.Builder()
|
||||||
|
// USAGE_ALARM is what makes Do Not Disturb's alarm exemption apply.
|
||||||
|
.setUsage(AudioAttributes.USAGE_ALARM)
|
||||||
|
.setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
private var player: MediaPlayer? = null
|
||||||
|
private var focusRequest: AudioFocusRequest? = null
|
||||||
|
private var ramp: Job? = null
|
||||||
|
|
||||||
|
/** Set by [stop], so a player that finished preparing after it is released too. */
|
||||||
|
private var stopped: Boolean = false
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tries the alarm's ringtone, then the device default, then the built-in
|
||||||
|
* default. Suspending, because [MediaPlayer.prepare] is blocking I/O and the
|
||||||
|
* moment the alarm rings is the worst possible moment to block the main
|
||||||
|
* thread with it.
|
||||||
|
*/
|
||||||
|
suspend fun start(scope: CoroutineScope, ringtoneUri: String?, rampSeconds: Int): Boolean {
|
||||||
|
stopped = false
|
||||||
|
val prepared = withContext(Dispatchers.IO) {
|
||||||
|
sources(ringtoneUri).firstNotNullOfOrNull { prepare(it) }
|
||||||
|
} ?: return false
|
||||||
|
|
||||||
|
// Stopped while we were preparing: release rather than start, or the ring
|
||||||
|
// would outlive the dismissal that asked for it.
|
||||||
|
if (stopped) {
|
||||||
|
release(prepared)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
player = prepared
|
||||||
|
|
||||||
|
requestFocus()
|
||||||
|
val rampFor = rampSeconds.coerceAtLeast(0).seconds
|
||||||
|
val initial = VolumeRamp.levelAt(Duration.ZERO, rampFor)
|
||||||
|
// The level is set before the first sample plays, so a ramp from silence
|
||||||
|
// does not begin with a burst at full volume.
|
||||||
|
runCatching { prepared.setVolume(initial, initial) }
|
||||||
|
runCatching { prepared.start() }
|
||||||
|
ramp = scope.launch {
|
||||||
|
var elapsed = Duration.ZERO
|
||||||
|
while (isActive && elapsed < rampFor) {
|
||||||
|
delay(AlarmRing.RAMP_TICK)
|
||||||
|
elapsed += AlarmRing.RAMP_TICK
|
||||||
|
val level = VolumeRamp.levelAt(elapsed, rampFor)
|
||||||
|
runCatching { player?.setVolume(level, level) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
fun stop() {
|
||||||
|
stopped = true
|
||||||
|
ramp?.cancel()
|
||||||
|
ramp = null
|
||||||
|
player?.let(::release)
|
||||||
|
player = null
|
||||||
|
abandonFocus()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun sources(ringtoneUri: String?): List<Uri> = listOfNotNull(
|
||||||
|
ringtoneUri?.takeIf { it.isNotBlank() }?.let(Uri::parse),
|
||||||
|
RingtoneManager.getActualDefaultRingtoneUri(context, RingtoneManager.TYPE_ALARM),
|
||||||
|
RingtoneManager.getDefaultUri(RingtoneManager.TYPE_ALARM),
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A prepared, not-yet-started player, or null. A source that throws has its
|
||||||
|
* player released here: one leaked [MediaPlayer] per unusable fallback is
|
||||||
|
* still a leak, and there are three fallbacks.
|
||||||
|
*/
|
||||||
|
private fun prepare(uri: Uri): MediaPlayer? {
|
||||||
|
val candidate = MediaPlayer()
|
||||||
|
return runCatching {
|
||||||
|
candidate.setAudioAttributes(attributes)
|
||||||
|
candidate.setDataSource(context, uri)
|
||||||
|
candidate.isLooping = true
|
||||||
|
candidate.prepare()
|
||||||
|
candidate
|
||||||
|
}.getOrElse {
|
||||||
|
release(candidate)
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun release(player: MediaPlayer) {
|
||||||
|
runCatching { player.stop() }
|
||||||
|
runCatching { player.release() }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** GAIN_TRANSIENT: an alarm interrupts what is playing; it does not duck it. */
|
||||||
|
private fun requestFocus() {
|
||||||
|
val request = AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN_TRANSIENT)
|
||||||
|
.setAudioAttributes(attributes)
|
||||||
|
.build()
|
||||||
|
focusRequest = request
|
||||||
|
runCatching { audioManager.requestAudioFocus(request) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun abandonFocus() {
|
||||||
|
focusRequest?.let { runCatching { audioManager.abandonAudioFocusRequest(it) } }
|
||||||
|
focusRequest = null
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,172 @@
|
|||||||
|
package de.jeanlucmakiola.clockula.alarm.ring
|
||||||
|
|
||||||
|
import android.app.Notification
|
||||||
|
import android.app.Service
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.content.pm.ServiceInfo
|
||||||
|
import android.os.Build
|
||||||
|
import android.os.IBinder
|
||||||
|
import android.os.PowerManager
|
||||||
|
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.domain.alarm.RingFallbackPolicy
|
||||||
|
import de.jeanlucmakiola.clockula.domain.alarm.RingPresentationPolicy
|
||||||
|
import de.jeanlucmakiola.clockula.domain.alarm.RingSession
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
import kotlinx.coroutines.cancel
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The ring itself: a foreground service so the alarm keeps sounding whatever the
|
||||||
|
* app is doing, and so a process kill mid-ring is something the system restores
|
||||||
|
* rather than something that silences the user's alarm.
|
||||||
|
*
|
||||||
|
* The session is read back from storage on every start rather than carried in
|
||||||
|
* the intent, so a service the system recreated knows exactly as much as one the
|
||||||
|
* engine started.
|
||||||
|
*/
|
||||||
|
@AndroidEntryPoint
|
||||||
|
class AlarmRingService : Service() {
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
lateinit var engine: AlarmEngine
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
lateinit var capabilities: AlarmCapabilities
|
||||||
|
|
||||||
|
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
|
||||||
|
|
||||||
|
private val audio by lazy { AlarmAudioPlayer(this) }
|
||||||
|
private val vibrator by lazy { AlarmVibrator(this) }
|
||||||
|
|
||||||
|
private var wakeLock: PowerManager.WakeLock? = null
|
||||||
|
private var ringing: Job? = null
|
||||||
|
|
||||||
|
override fun onBind(intent: Intent?): IBinder? = null
|
||||||
|
|
||||||
|
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||||
|
if (intent?.action == ACTION_STOP) {
|
||||||
|
stop()
|
||||||
|
return START_NOT_STICKY
|
||||||
|
}
|
||||||
|
|
||||||
|
// The whole ring is released, not just the coroutine: cancelling the job
|
||||||
|
// alone would orphan a MediaPlayer the previous start had already
|
||||||
|
// prepared, and an orphaned looping alarm cannot be stopped again.
|
||||||
|
releaseRing()
|
||||||
|
// Before any suspension point, and on every path: the platform gives a
|
||||||
|
// foreground service a few seconds to post its notification, and a
|
||||||
|
// database read is not something to spend them on.
|
||||||
|
startForegroundPreparing()
|
||||||
|
ringing = scope.launch {
|
||||||
|
val session = engine.ringSession()
|
||||||
|
if (session == null) {
|
||||||
|
// Dismissed between the start and here: nothing to ring.
|
||||||
|
stop()
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
ring(session)
|
||||||
|
}
|
||||||
|
// Re-delivered rather than dropped: a killed ring is resumed with the
|
||||||
|
// same intent, and the session is re-read from storage anyway.
|
||||||
|
return START_REDELIVER_INTENT
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDestroy() {
|
||||||
|
releaseRing()
|
||||||
|
scope.cancel()
|
||||||
|
super.onDestroy()
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun ring(session: RingSession) {
|
||||||
|
startForeground(session)
|
||||||
|
acquireWakeLock()
|
||||||
|
|
||||||
|
val audible = audio.start(scope, session.settings.ringtoneUri, session.settings.volumeRampSeconds)
|
||||||
|
// Silence is not one of the outcomes: with no audio source at all the
|
||||||
|
// alarm vibrates even when the user switched vibration off.
|
||||||
|
if (RingFallbackPolicy.vibrationRequired(audible, session.settings.vibrate)) vibrator.start()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The placeholder the service goes foreground with immediately; [ring]
|
||||||
|
* replaces it with the alarm's own notification as soon as the session is
|
||||||
|
* read. Same channel and same id, so the user sees one notification.
|
||||||
|
*/
|
||||||
|
private fun startForegroundPreparing() {
|
||||||
|
startForeground(RingNotifications.preparing(this))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun startForeground(session: RingSession) {
|
||||||
|
startForeground(
|
||||||
|
RingNotifications.ringing(
|
||||||
|
context = this,
|
||||||
|
alarm = session.alarm,
|
||||||
|
presentation = RingPresentationPolicy.forCapabilities(capabilities.snapshot()),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun startForeground(notification: Notification) {
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
|
||||||
|
// systemExempted: Android's own guidance names an app holding an
|
||||||
|
// exact-alarm permission and continuing an alarm in the background.
|
||||||
|
startForeground(
|
||||||
|
RingNotifications.RING_NOTIFICATION_ID,
|
||||||
|
notification,
|
||||||
|
ServiceInfo.FOREGROUND_SERVICE_TYPE_SYSTEM_EXEMPTED,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
startForeground(RingNotifications.RING_NOTIFICATION_ID, notification)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun acquireWakeLock() {
|
||||||
|
if (wakeLock != null) return
|
||||||
|
val power = getSystemService(PowerManager::class.java)
|
||||||
|
wakeLock = power.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, WAKE_LOCK_TAG).apply {
|
||||||
|
setReferenceCounted(false)
|
||||||
|
runCatching { acquire(WAKE_LOCK_TIMEOUT_MILLIS) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun releaseRing() {
|
||||||
|
ringing?.cancel()
|
||||||
|
ringing = null
|
||||||
|
audio.stop()
|
||||||
|
vibrator.stop()
|
||||||
|
wakeLock?.let { if (it.isHeld) runCatching { it.release() } }
|
||||||
|
wakeLock = null
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun stop() {
|
||||||
|
releaseRing()
|
||||||
|
stopForeground(STOP_FOREGROUND_REMOVE)
|
||||||
|
stopSelf()
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val ACTION_STOP = AlarmIntents.ACTION_DISMISS + ".SERVICE_STOP"
|
||||||
|
private const val WAKE_LOCK_TAG = "clockula:alarm-ring"
|
||||||
|
|
||||||
|
/** A backstop on the backstop: the ring window is ten minutes. */
|
||||||
|
private const val WAKE_LOCK_TIMEOUT_MILLIS = 15L * 60L * 1_000L
|
||||||
|
|
||||||
|
fun start(context: Context) {
|
||||||
|
context.startForegroundService(Intent(context, AlarmRingService::class.java))
|
||||||
|
}
|
||||||
|
|
||||||
|
fun stop(context: Context) {
|
||||||
|
context.startService(
|
||||||
|
Intent(context, AlarmRingService::class.java).setAction(ACTION_STOP),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
package de.jeanlucmakiola.clockula.alarm.ring
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.media.AudioAttributes
|
||||||
|
import android.os.Build
|
||||||
|
import android.os.VibrationEffect
|
||||||
|
import android.os.Vibrator
|
||||||
|
import android.os.VibratorManager
|
||||||
|
import de.jeanlucmakiola.clockula.domain.alarm.AlarmVibrationPattern
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The waveform, looped, with `USAGE_ALARM` attributes so Do Not Disturb treats
|
||||||
|
* it the way it treats the sound. `minSdk` is 29, so `VibratorManager` (API 31+)
|
||||||
|
* is guarded and the older accessor stands in below it.
|
||||||
|
*/
|
||||||
|
class AlarmVibrator(private val context: Context) {
|
||||||
|
|
||||||
|
private val attributes: AudioAttributes = AudioAttributes.Builder()
|
||||||
|
.setUsage(AudioAttributes.USAGE_ALARM)
|
||||||
|
.setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
private val vibrator: Vibrator? = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||||
|
context.getSystemService(VibratorManager::class.java)?.defaultVibrator
|
||||||
|
} else {
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
context.getSystemService(Vibrator::class.java)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun start() {
|
||||||
|
val device = vibrator?.takeIf { it.hasVibrator() } ?: return
|
||||||
|
val effect = VibrationEffect.createWaveform(
|
||||||
|
AlarmVibrationPattern.TIMINGS,
|
||||||
|
AlarmVibrationPattern.AMPLITUDES,
|
||||||
|
AlarmVibrationPattern.REPEAT_INDEX,
|
||||||
|
)
|
||||||
|
runCatching {
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
device.vibrate(effect, attributes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun stop() {
|
||||||
|
runCatching { vibrator?.cancel() }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,188 @@
|
|||||||
|
package de.jeanlucmakiola.clockula.alarm.ring
|
||||||
|
|
||||||
|
import android.app.Notification
|
||||||
|
import android.app.NotificationChannel
|
||||||
|
import android.app.NotificationManager
|
||||||
|
import android.app.PendingIntent
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import androidx.core.app.NotificationCompat
|
||||||
|
import de.jeanlucmakiola.clockula.R
|
||||||
|
import de.jeanlucmakiola.clockula.alarm.AlarmIntents
|
||||||
|
import de.jeanlucmakiola.clockula.alarm.receiver.AlarmActionReceiver
|
||||||
|
import de.jeanlucmakiola.clockula.domain.Alarm
|
||||||
|
import de.jeanlucmakiola.clockula.domain.alarm.RingPresentation
|
||||||
|
import de.jeanlucmakiola.clockula.ui.ring.AlarmRingActivity
|
||||||
|
import java.time.Instant as JavaInstant
|
||||||
|
import java.time.LocalTime
|
||||||
|
import java.time.ZoneId
|
||||||
|
import java.time.format.DateTimeFormatter
|
||||||
|
import java.time.format.FormatStyle
|
||||||
|
import kotlin.time.Instant
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The two notification surfaces of a ring cycle: the one the foreground service
|
||||||
|
* posts while the alarm is sounding, and the one that says an alarm is snoozed.
|
||||||
|
*
|
||||||
|
* The ringing notification is never how the alarm makes a sound — the audio
|
||||||
|
* belongs to the service — so a denied notification permission costs the user
|
||||||
|
* the *screen*, never the alarm.
|
||||||
|
*/
|
||||||
|
object RingNotifications {
|
||||||
|
|
||||||
|
const val RING_CHANNEL_ID: String = "alarm_ring"
|
||||||
|
const val SNOOZED_CHANNEL_ID: String = "alarm_snoozed"
|
||||||
|
|
||||||
|
/** One ringing alarm at a time (D7), so one id. */
|
||||||
|
const val RING_NOTIFICATION_ID: Int = 1_001
|
||||||
|
|
||||||
|
/** Creates both channels. Idempotent; called from the app class on every start. */
|
||||||
|
fun createChannels(context: Context) {
|
||||||
|
val manager = context.getSystemService(NotificationManager::class.java)
|
||||||
|
|
||||||
|
// IMPORTANCE_HIGH is what makes a heads-up notification possible when the
|
||||||
|
// full-screen intent is denied — the degradation must still be loud.
|
||||||
|
val ring = NotificationChannel(
|
||||||
|
RING_CHANNEL_ID,
|
||||||
|
context.getString(R.string.channel_alarm_ring_name),
|
||||||
|
NotificationManager.IMPORTANCE_HIGH,
|
||||||
|
).apply {
|
||||||
|
description = context.getString(R.string.channel_alarm_ring_description)
|
||||||
|
// The service owns the sound and the vibration, so the channel adds
|
||||||
|
// neither — two ringtones at once is not a feature.
|
||||||
|
setSound(null, null)
|
||||||
|
enableVibration(false)
|
||||||
|
setBypassDnd(true)
|
||||||
|
lockscreenVisibility = Notification.VISIBILITY_PUBLIC
|
||||||
|
}
|
||||||
|
|
||||||
|
val snoozed = NotificationChannel(
|
||||||
|
SNOOZED_CHANNEL_ID,
|
||||||
|
context.getString(R.string.channel_alarm_snoozed_name),
|
||||||
|
NotificationManager.IMPORTANCE_LOW,
|
||||||
|
).apply {
|
||||||
|
description = context.getString(R.string.channel_alarm_snoozed_description)
|
||||||
|
}
|
||||||
|
|
||||||
|
manager.createNotificationChannel(ring)
|
||||||
|
manager.createNotificationChannel(snoozed)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The notification the service goes foreground with the instant it starts,
|
||||||
|
* before it has read anything: `startForeground` must not wait on a database
|
||||||
|
* read. [ringing] replaces it under the same id a moment later.
|
||||||
|
*/
|
||||||
|
fun preparing(context: Context): Notification =
|
||||||
|
NotificationCompat.Builder(context, RING_CHANNEL_ID)
|
||||||
|
.setSmallIcon(R.drawable.ic_notification)
|
||||||
|
.setContentTitle(context.getString(R.string.alarm_notification_title))
|
||||||
|
.setCategory(NotificationCompat.CATEGORY_ALARM)
|
||||||
|
.setPriority(NotificationCompat.PRIORITY_MAX)
|
||||||
|
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
|
||||||
|
.setOngoing(true)
|
||||||
|
.setAutoCancel(false)
|
||||||
|
.setContentIntent(showPendingIntent(context))
|
||||||
|
.build()
|
||||||
|
|
||||||
|
/** The ringing notification, presented as [presentation] says the platform allows. */
|
||||||
|
fun ringing(
|
||||||
|
context: Context,
|
||||||
|
alarm: Alarm,
|
||||||
|
presentation: RingPresentation,
|
||||||
|
): Notification {
|
||||||
|
val show = showPendingIntent(context)
|
||||||
|
val builder = NotificationCompat.Builder(context, RING_CHANNEL_ID)
|
||||||
|
.setSmallIcon(R.drawable.ic_notification)
|
||||||
|
.setContentTitle(alarm.label.ifBlank { context.getString(R.string.alarm_notification_title) })
|
||||||
|
.setContentText(formatTime(context, alarm.time.hour, alarm.time.minute))
|
||||||
|
.setCategory(NotificationCompat.CATEGORY_ALARM)
|
||||||
|
.setPriority(NotificationCompat.PRIORITY_MAX)
|
||||||
|
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
|
||||||
|
.setOngoing(true)
|
||||||
|
.setAutoCancel(false)
|
||||||
|
.setContentIntent(show)
|
||||||
|
.addAction(
|
||||||
|
R.drawable.ic_notification,
|
||||||
|
context.getString(R.string.alarm_action_snooze),
|
||||||
|
actionPendingIntent(context, AlarmIntents.ACTION_SNOOZE, AlarmIntents.REQUEST_SNOOZE, alarm.id),
|
||||||
|
)
|
||||||
|
.addAction(
|
||||||
|
R.drawable.ic_notification,
|
||||||
|
context.getString(R.string.alarm_action_dismiss),
|
||||||
|
actionPendingIntent(context, AlarmIntents.ACTION_DISMISS, AlarmIntents.REQUEST_DISMISS, alarm.id),
|
||||||
|
)
|
||||||
|
|
||||||
|
if (presentation.useFullScreenIntent) builder.setFullScreenIntent(show, true)
|
||||||
|
return builder.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "Snoozed until 07:10", with a Dismiss action so the cycle can end from here. */
|
||||||
|
fun snoozed(context: Context, alarm: Alarm, until: Instant): Notification =
|
||||||
|
NotificationCompat.Builder(context, SNOOZED_CHANNEL_ID)
|
||||||
|
.setSmallIcon(R.drawable.ic_notification)
|
||||||
|
.setContentTitle(alarm.label.ifBlank { context.getString(R.string.alarm_notification_title) })
|
||||||
|
.setContentText(context.getString(R.string.alarm_snoozed_until, formatInstant(context, until)))
|
||||||
|
.setCategory(NotificationCompat.CATEGORY_ALARM)
|
||||||
|
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
|
||||||
|
.setOnlyAlertOnce(true)
|
||||||
|
.setContentIntent(showPendingIntent(context))
|
||||||
|
.addAction(
|
||||||
|
R.drawable.ic_notification,
|
||||||
|
context.getString(R.string.alarm_action_dismiss),
|
||||||
|
actionPendingIntent(context, AlarmIntents.ACTION_DISMISS, AlarmIntents.REQUEST_DISMISS, alarm.id),
|
||||||
|
)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
/** Distinct per alarm, so two snoozed alarms are two lines rather than one. */
|
||||||
|
fun snoozedNotificationId(alarmId: Long): Int = (SNOOZED_ID_BASE + alarmId).toInt()
|
||||||
|
|
||||||
|
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
|
||||||
|
},
|
||||||
|
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun actionPendingIntent(
|
||||||
|
context: Context,
|
||||||
|
action: String,
|
||||||
|
requestCode: Int,
|
||||||
|
alarmId: Long,
|
||||||
|
): PendingIntent = PendingIntent.getBroadcast(
|
||||||
|
context,
|
||||||
|
// Per alarm, so two snoozed alarms' Dismiss buttons do not overwrite each
|
||||||
|
// other's extras the way one shared request code would.
|
||||||
|
requestCode * REQUEST_CODE_STRIDE + (alarmId % REQUEST_CODE_STRIDE).toInt(),
|
||||||
|
Intent(context, AlarmActionReceiver::class.java).apply {
|
||||||
|
this.action = action
|
||||||
|
putExtra(AlarmIntents.EXTRA_ALARM_ID, alarmId)
|
||||||
|
},
|
||||||
|
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun formatTime(context: Context, hour: Int, minute: Int): String =
|
||||||
|
format(context, LocalTime.of(hour, minute))
|
||||||
|
|
||||||
|
private fun formatInstant(context: Context, instant: Instant): String = format(
|
||||||
|
context,
|
||||||
|
JavaInstant.ofEpochMilli(instant.toEpochMilliseconds()).atZone(ZoneId.systemDefault()).toLocalTime(),
|
||||||
|
)
|
||||||
|
|
||||||
|
/** The device's 12/24-hour choice, then the locale's own short form. */
|
||||||
|
private fun format(context: Context, time: LocalTime): String {
|
||||||
|
val pattern = if (android.text.format.DateFormat.is24HourFormat(context)) {
|
||||||
|
DateTimeFormatter.ofPattern("HH:mm")
|
||||||
|
} else {
|
||||||
|
DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT)
|
||||||
|
}
|
||||||
|
return pattern.format(time)
|
||||||
|
}
|
||||||
|
|
||||||
|
private const val SNOOZED_ID_BASE = 2_000L
|
||||||
|
|
||||||
|
/** Keeps a derived request code clear of the scheduler's own small ones. */
|
||||||
|
private const val REQUEST_CODE_STRIDE = 100_000
|
||||||
|
}
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
package de.jeanlucmakiola.clockula.ui.ring
|
||||||
|
|
||||||
|
import android.os.Bundle
|
||||||
|
import androidx.activity.ComponentActivity
|
||||||
|
import androidx.activity.compose.setContent
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedButton
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.lifecycle.lifecycleScope
|
||||||
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
|
import de.jeanlucmakiola.clockula.R
|
||||||
|
import de.jeanlucmakiola.clockula.alarm.AlarmEngine
|
||||||
|
import de.jeanlucmakiola.clockula.alarm.RingCoordinator
|
||||||
|
import de.jeanlucmakiola.clockula.domain.alarm.RingSession
|
||||||
|
import de.jeanlucmakiola.clockula.ui.theme.ClockulaTheme
|
||||||
|
import kotlinx.coroutines.flow.collectLatest
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The ring screen's **plumbing only** — the window flags that cannot wait, and
|
||||||
|
* finishing when the ring ends. M4 owns what it looks like; what is here is a
|
||||||
|
* deliberately plain placeholder so the milestone ships a working path from a
|
||||||
|
* fired alarm to a dismissed one without pre-empting M4's design.
|
||||||
|
*/
|
||||||
|
@AndroidEntryPoint
|
||||||
|
class AlarmRingActivity : ComponentActivity() {
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
lateinit var engine: AlarmEngine
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
lateinit var ring: RingCoordinator
|
||||||
|
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
super.onCreate(savedInstanceState)
|
||||||
|
showOverLockScreen()
|
||||||
|
|
||||||
|
lifecycleScope.launch {
|
||||||
|
val session = engine.ringSession()
|
||||||
|
// Nothing ringing: dismissed from the notification while the screen
|
||||||
|
// was coming up, or a stale showIntent from the status bar.
|
||||||
|
if (session == null) {
|
||||||
|
finish()
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
setContent {
|
||||||
|
ClockulaTheme { RingPlaceholder(session, ::snooze, ::dismiss) }
|
||||||
|
}
|
||||||
|
// The ring ending is what closes this screen, whoever ended it. The
|
||||||
|
// coordinator is seeded null in a fresh process, so storage — not
|
||||||
|
// memory — is what settles whether the ring is really over.
|
||||||
|
ring.ringing.collectLatest { alarmId ->
|
||||||
|
if (alarmId == null && engine.ringSession() == null) finish()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun snooze() {
|
||||||
|
lifecycleScope.launch {
|
||||||
|
engine.snooze(sessionAlarmId())
|
||||||
|
finish()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun dismiss() {
|
||||||
|
lifecycleScope.launch {
|
||||||
|
engine.dismiss(sessionAlarmId())
|
||||||
|
finish()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun sessionAlarmId(): Long = engine.ringSession()?.alarm?.id ?: 0L
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shown over the lock screen with the display turned on: an alarm the user
|
||||||
|
* has to unlock to silence is not an alarm. `minSdk` is 29, so the
|
||||||
|
* pre-API-27 window flags are not needed.
|
||||||
|
*/
|
||||||
|
private fun showOverLockScreen() {
|
||||||
|
setShowWhenLocked(true)
|
||||||
|
setTurnScreenOn(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Stands in for M4's ring screen. No challenge, no motion, no ViewModel — by design. */
|
||||||
|
@Composable
|
||||||
|
private fun RingPlaceholder(
|
||||||
|
session: RingSession,
|
||||||
|
onSnooze: () -> Unit,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
) {
|
||||||
|
Surface(Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.surface) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.fillMaxSize().padding(24.dp),
|
||||||
|
verticalArrangement = Arrangement.Center,
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "%02d:%02d".format(session.alarm.time.hour, session.alarm.time.minute),
|
||||||
|
style = MaterialTheme.typography.displayLarge,
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = session.alarm.label.ifBlank { stringResource(R.string.alarm_notification_title) },
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.padding(top = 32.dp),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
|
) {
|
||||||
|
if (session.canSnooze) {
|
||||||
|
OutlinedButton(onClick = onSnooze) {
|
||||||
|
Text(stringResource(R.string.alarm_action_snooze))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Button(onClick = onDismiss) {
|
||||||
|
Text(stringResource(R.string.alarm_action_dismiss))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,16 @@
|
|||||||
<string name="app_name">Clockula</string>
|
<string name="app_name">Clockula</string>
|
||||||
<string name="app_tagline">A modern Material 3 Expressive clock app.\nComing into bloom.</string>
|
<string name="app_tagline">A modern Material 3 Expressive clock app.\nComing into bloom.</string>
|
||||||
|
|
||||||
|
<!-- Alarms (M3): the notification channels and the ring actions -->
|
||||||
|
<string name="channel_alarm_ring_name">Ringing alarms</string>
|
||||||
|
<string name="channel_alarm_ring_description">The full-screen alarm while it is ringing. Turning this off does not silence an alarm.</string>
|
||||||
|
<string name="channel_alarm_snoozed_name">Snoozed alarms</string>
|
||||||
|
<string name="channel_alarm_snoozed_description">A quiet reminder that an alarm is snoozed, and when it comes back.</string>
|
||||||
|
<string name="alarm_notification_title">Alarm</string>
|
||||||
|
<string name="alarm_action_snooze">Snooze</string>
|
||||||
|
<string name="alarm_action_dismiss">Dismiss</string>
|
||||||
|
<string name="alarm_snoozed_until">Snoozed until %1$s</string>
|
||||||
|
|
||||||
<!-- Crash reporting (floret-kit core-crash) -->
|
<!-- Crash reporting (floret-kit core-crash) -->
|
||||||
<string name="crash_report_issue_title">Crash report</string>
|
<string name="crash_report_issue_title">Crash report</string>
|
||||||
<string name="report_issue_url" translatable="false">https://codeberg.org/jlmakiola/clockula/issues/new</string>
|
<string name="report_issue_url" translatable="false">https://codeberg.org/jlmakiola/clockula/issues/new</string>
|
||||||
|
|||||||
Reference in New Issue
Block a user