feat(alarm): setAlarmClock scheduling with the exact-alarm ladder
Only the single next alarm is ever registered, via setAlarmClock, so the platform draws the status-bar icon and the alarm is exempt from doze. A second slot holds the auto-silence backstop, kept deliberately separate so cancelling one cannot cancel the other. USE_EXACT_ALARM is declared for the versions that grant it outright, with a SCHEDULE_EXACT_ALARM fallback path behind canScheduleExactAlarms() above that boundary. Capabilities are exposed as a snapshot; asking the user for the permission is M4's job and explaining it is M10's. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
+46
@@ -0,0 +1,46 @@
|
||||
package de.jeanlucmakiola.clockula.alarm.android
|
||||
|
||||
import android.app.AlarmManager
|
||||
import android.app.NotificationManager
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import de.jeanlucmakiola.clockula.alarm.AlarmCapabilities
|
||||
import de.jeanlucmakiola.clockula.domain.alarm.CapabilitySnapshot
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* What the platform will currently let the app do, read afresh every time: all
|
||||
* three of these can be revoked while the app is not looking. `minSdk` is 29, so
|
||||
* each newer API is guarded and degrades to the answer that was true before it
|
||||
* existed — on those releases the capability was unconditional.
|
||||
*/
|
||||
@Singleton
|
||||
class AndroidAlarmCapabilities @Inject constructor(
|
||||
@param:ApplicationContext private val context: Context,
|
||||
) : AlarmCapabilities {
|
||||
|
||||
override fun snapshot(): CapabilitySnapshot = CapabilitySnapshot(
|
||||
canScheduleExactAlarms = canScheduleExactAlarms(),
|
||||
canUseFullScreenIntent = canUseFullScreenIntent(),
|
||||
notificationsEnabled = notifications().areNotificationsEnabled(),
|
||||
)
|
||||
|
||||
private fun canScheduleExactAlarms(): Boolean =
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
context.getSystemService(AlarmManager::class.java).canScheduleExactAlarms()
|
||||
} else {
|
||||
true
|
||||
}
|
||||
|
||||
private fun canUseFullScreenIntent(): Boolean =
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
|
||||
notifications().canUseFullScreenIntent()
|
||||
} else {
|
||||
true
|
||||
}
|
||||
|
||||
private fun notifications(): NotificationManager =
|
||||
context.getSystemService(NotificationManager::class.java)
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
package de.jeanlucmakiola.clockula.alarm.android
|
||||
|
||||
import android.app.AlarmManager
|
||||
import android.app.PendingIntent
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import de.jeanlucmakiola.clockula.alarm.AlarmCapabilities
|
||||
import de.jeanlucmakiola.clockula.alarm.AlarmIntents
|
||||
import de.jeanlucmakiola.clockula.alarm.AlarmScheduler
|
||||
import de.jeanlucmakiola.clockula.alarm.receiver.AlarmFireReceiver
|
||||
import de.jeanlucmakiola.clockula.domain.alarm.ScheduleMode
|
||||
import de.jeanlucmakiola.clockula.domain.alarm.SchedulingPolicy
|
||||
import de.jeanlucmakiola.clockula.ui.ring.AlarmRingActivity
|
||||
import kotlin.time.Instant
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* Two AlarmManager slots, deliberately separate. The user's next alarm goes
|
||||
* through `setAlarmClock`, which is the only variant that populates
|
||||
* `getNextAlarmClock()` — the status-bar icon and the lockscreen line. The
|
||||
* auto-silence backstop goes through its own PendingIntent, so an internal
|
||||
* housekeeping time can never overwrite what the system shows the user.
|
||||
*
|
||||
* Translation only: which mode to use is [SchedulingPolicy]'s decision.
|
||||
*/
|
||||
@Singleton
|
||||
class AndroidAlarmScheduler @Inject constructor(
|
||||
@param:ApplicationContext private val context: Context,
|
||||
private val capabilities: AlarmCapabilities,
|
||||
) : AlarmScheduler {
|
||||
|
||||
private val manager: AlarmManager = context.getSystemService(AlarmManager::class.java)
|
||||
|
||||
override fun scheduleNext(fireAt: Instant, alarmId: Long): ScheduleMode {
|
||||
val at = fireAt.toEpochMilliseconds()
|
||||
val fire = firePendingIntent(alarmId, fireAt)
|
||||
val mode = SchedulingPolicy.modeFor(capabilities.snapshot().canScheduleExactAlarms)
|
||||
|
||||
if (mode == ScheduleMode.EXACT_ALARM_CLOCK) {
|
||||
val scheduled = runCatching {
|
||||
manager.setAlarmClock(AlarmManager.AlarmClockInfo(at, showPendingIntent()), fire)
|
||||
}.isSuccess
|
||||
// The permission can go away between the snapshot and the call. Late
|
||||
// is survivable; silent is not.
|
||||
if (scheduled) return ScheduleMode.EXACT_ALARM_CLOCK
|
||||
}
|
||||
|
||||
manager.setAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, at, fire)
|
||||
return ScheduleMode.INEXACT_FALLBACK
|
||||
}
|
||||
|
||||
override fun cancelNext() = manager.cancel(firePendingIntent(alarmId = 0L, fireAt = null))
|
||||
|
||||
override fun scheduleAutoSilence(at: Instant, alarmId: Long) {
|
||||
val pendingIntent = autoSilencePendingIntent(alarmId)
|
||||
val millis = at.toEpochMilliseconds()
|
||||
val exact = runCatching {
|
||||
manager.setExactAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, millis, pendingIntent)
|
||||
}.isSuccess
|
||||
if (!exact) manager.setAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, millis, pendingIntent)
|
||||
}
|
||||
|
||||
override fun cancelAutoSilence() = manager.cancel(autoSilencePendingIntent(alarmId = 0L))
|
||||
|
||||
override fun systemNextAlarm(): Instant? =
|
||||
manager.nextAlarmClock?.let { Instant.fromEpochMilliseconds(it.triggerTime) }
|
||||
|
||||
/**
|
||||
* One request code per slot, so the cancel below finds the same PendingIntent
|
||||
* the schedule above created — the extras are updated, never matched on.
|
||||
*/
|
||||
private fun firePendingIntent(alarmId: Long, fireAt: Instant?): PendingIntent = PendingIntent.getBroadcast(
|
||||
context,
|
||||
AlarmIntents.REQUEST_FIRE,
|
||||
Intent(context, AlarmFireReceiver::class.java).apply {
|
||||
action = AlarmIntents.ACTION_FIRE
|
||||
putExtra(AlarmIntents.EXTRA_ALARM_ID, alarmId)
|
||||
putExtra(AlarmIntents.EXTRA_FIRE_AT, fireAt?.toEpochMilliseconds() ?: 0L)
|
||||
},
|
||||
PENDING_INTENT_FLAGS,
|
||||
)
|
||||
|
||||
private fun autoSilencePendingIntent(alarmId: Long): PendingIntent = PendingIntent.getBroadcast(
|
||||
context,
|
||||
AlarmIntents.REQUEST_AUTO_SILENCE,
|
||||
Intent(context, AlarmFireReceiver::class.java).apply {
|
||||
action = AlarmIntents.ACTION_AUTO_SILENCE
|
||||
putExtra(AlarmIntents.EXTRA_ALARM_ID, alarmId)
|
||||
},
|
||||
PENDING_INTENT_FLAGS,
|
||||
)
|
||||
|
||||
/** What the system opens when the user taps the status-bar alarm icon. */
|
||||
private fun showPendingIntent(): 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
|
||||
},
|
||||
PENDING_INTENT_FLAGS,
|
||||
)
|
||||
|
||||
private companion object {
|
||||
const val PENDING_INTENT_FLAGS: Int =
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user