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