summaryrefslogtreecommitdiff
path: root/src/com/android/calendar/alerts/AlarmScheduler.kt
diff options
context:
space:
mode:
Diffstat (limited to 'src/com/android/calendar/alerts/AlarmScheduler.kt')
-rw-r--r--src/com/android/calendar/alerts/AlarmScheduler.kt352
1 files changed, 352 insertions, 0 deletions
diff --git a/src/com/android/calendar/alerts/AlarmScheduler.kt b/src/com/android/calendar/alerts/AlarmScheduler.kt
new file mode 100644
index 00000000..c93bbb04
--- /dev/null
+++ b/src/com/android/calendar/alerts/AlarmScheduler.kt
@@ -0,0 +1,352 @@
+/*
+ * Copyright (C) 2021 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.android.calendar.alerts
+
+import android.app.AlarmManager
+import android.app.PendingIntent
+import android.content.ContentResolver
+import android.content.ContentUris
+import android.content.Context
+import android.content.Intent
+import android.database.Cursor
+import android.net.Uri
+import android.provider.CalendarContract
+import android.provider.CalendarContract.Events
+import android.provider.CalendarContract.Instances
+import android.provider.CalendarContract.Reminders
+import android.text.format.DateUtils
+import android.text.format.Time
+import android.util.Log
+import com.android.calendar.Utils
+import java.util.HashMap
+import java.util.List
+
+/**
+ * Schedules the next EVENT_REMINDER_APP broadcast with AlarmManager, by querying the events
+ * and reminders tables for the next upcoming alert.
+ */
+object AlarmScheduler {
+ private const val TAG = "AlarmScheduler"
+ private val INSTANCES_WHERE: String = (Events.VISIBLE.toString() + "=? AND " +
+ Instances.BEGIN + ">=? AND " + Instances.BEGIN + "<=? AND " +
+ Events.ALL_DAY + "=?")
+ val INSTANCES_PROJECTION = arrayOf<String>(
+ Instances.EVENT_ID,
+ Instances.BEGIN,
+ Instances.ALL_DAY
+ )
+ private const val INSTANCES_INDEX_EVENTID = 0
+ private const val INSTANCES_INDEX_BEGIN = 1
+ private const val INSTANCES_INDEX_ALL_DAY = 2
+ private val REMINDERS_WHERE: String = (Reminders.METHOD.toString() + "=1 AND " +
+ Reminders.EVENT_ID + " IN ")
+ val REMINDERS_PROJECTION = arrayOf<String>(
+ Reminders.EVENT_ID,
+ Reminders.MINUTES,
+ Reminders.METHOD
+ )
+ private const val REMINDERS_INDEX_EVENT_ID = 0
+ private const val REMINDERS_INDEX_MINUTES = 1
+ private const val REMINDERS_INDEX_METHOD = 2
+
+ // Add a slight delay for the EVENT_REMINDER_APP broadcast for a couple reasons:
+ // (1) so that the concurrent reminder broadcast from the provider doesn't result
+ // in a double ring, and (2) some OEMs modified the provider to not add an alert to
+ // the CalendarAlerts table until the alert time, so for the unbundled app's
+ // notifications to work on these devices, a delay ensures that AlertService won't
+ // read from the CalendarAlerts table until the alert is present.
+ const val ALARM_DELAY_MS = 1000
+
+ // The reminders query looks like "SELECT ... AND eventId IN 101,102,202,...". This
+ // sets the max # of events in the query before batching into multiple queries, to
+ // limit the SQL query length.
+ private const val REMINDER_QUERY_BATCH_SIZE = 50
+
+ // We really need to query for reminder times that fall in some interval, but
+ // the Reminders table only stores the reminder interval (10min, 15min, etc), and
+ // we cannot do the join with the Events table to calculate the actual alert time
+ // from outside of the provider. So the best we can do for now consider events
+ // whose start times begin within some interval (ie. 1 week out). This means
+ // reminders which are configured for more than 1 week out won't fire on time. We
+ // can minimize this to being only 1 day late by putting a 1 day max on the alarm time.
+ private val EVENT_LOOKAHEAD_WINDOW_MS: Long = DateUtils.WEEK_IN_MILLIS
+ private val MAX_ALARM_ELAPSED_MS: Long = DateUtils.DAY_IN_MILLIS
+
+ /**
+ * Schedules the nearest upcoming alarm, to refresh notifications.
+ *
+ * This is historically done in the provider but we dupe this here so the unbundled
+ * app will work on devices that have modified this portion of the provider. This
+ * has the limitation of querying events within some interval from now (ie. looks at
+ * reminders for all events occurring in the next week). This means for example,
+ * a 2 week notification will not fire on time.
+ */
+ @JvmStatic fun scheduleNextAlarm(context: Context) {
+ scheduleNextAlarm(
+ context, AlertUtils.createAlarmManager(context),
+ REMINDER_QUERY_BATCH_SIZE, System.currentTimeMillis()
+ )
+ }
+
+ // VisibleForTesting
+ @JvmStatic fun scheduleNextAlarm(
+ context: Context,
+ alarmManager: AlarmManagerInterface?,
+ batchSize: Int,
+ currentMillis: Long
+ ) {
+ var instancesCursor: Cursor? = null
+ try {
+ instancesCursor = queryUpcomingEvents(
+ context, context.getContentResolver(),
+ currentMillis
+ )
+ if (instancesCursor != null) {
+ queryNextReminderAndSchedule(
+ instancesCursor,
+ context,
+ context.getContentResolver(),
+ alarmManager as AlarmManagerInterface,
+ batchSize,
+ currentMillis
+ )
+ }
+ } finally {
+ if (instancesCursor != null) {
+ instancesCursor.close()
+ }
+ }
+ }
+
+ /**
+ * Queries events starting within a fixed interval from now.
+ */
+ @JvmStatic private fun queryUpcomingEvents(
+ context: Context,
+ contentResolver: ContentResolver,
+ currentMillis: Long
+ ): Cursor? {
+ val time = Time()
+ time.normalize(false)
+ val localOffset: Long = time.gmtoff * 1000
+ val localStartMax =
+ currentMillis + EVENT_LOOKAHEAD_WINDOW_MS
+ val utcStartMin = currentMillis - localOffset
+ val utcStartMax =
+ utcStartMin + EVENT_LOOKAHEAD_WINDOW_MS
+
+ // Expand Instances table range by a day on either end to account for
+ // all-day events.
+ val uriBuilder: Uri.Builder = Instances.CONTENT_URI.buildUpon()
+ ContentUris.appendId(uriBuilder, currentMillis - DateUtils.DAY_IN_MILLIS)
+ ContentUris.appendId(uriBuilder, localStartMax + DateUtils.DAY_IN_MILLIS)
+
+ // Build query for all events starting within the fixed interval.
+ val queryBuilder = StringBuilder()
+ queryBuilder.append("(")
+ queryBuilder.append(INSTANCES_WHERE)
+ queryBuilder.append(") OR (")
+ queryBuilder.append(INSTANCES_WHERE)
+ queryBuilder.append(")")
+ val queryArgs = arrayOf(
+ // allday selection
+ "1", /* visible = ? */
+ utcStartMin.toString(), /* begin >= ? */
+ utcStartMax.toString(), /* begin <= ? */
+ "1", /* allDay = ? */ // non-allday selection
+ "1", /* visible = ? */
+ currentMillis.toString(), /* begin >= ? */
+ localStartMax.toString(), /* begin <= ? */
+ "0" /* allDay = ? */
+ )
+
+ val cursor: Cursor? = contentResolver.query(uriBuilder.build(), INSTANCES_PROJECTION,
+ queryBuilder.toString(), queryArgs, null)
+ return cursor
+ }
+
+ /**
+ * Queries for all the reminders of the events in the instancesCursor, and schedules
+ * the alarm for the next upcoming reminder.
+ */
+ @JvmStatic private fun queryNextReminderAndSchedule(
+ instancesCursor: Cursor,
+ context: Context,
+ contentResolver: ContentResolver,
+ alarmManager: AlarmManagerInterface,
+ batchSize: Int,
+ currentMillis: Long
+ ) {
+ if (AlertService.DEBUG) {
+ val eventCount: Int = instancesCursor.getCount()
+ if (eventCount == 0) {
+ Log.d(TAG, "No events found starting within 1 week.")
+ } else {
+ Log.d(TAG, "Query result count for events starting within 1 week: $eventCount")
+ }
+ }
+
+ // Put query results of all events starting within some interval into map of event ID to
+ // local start time.
+ val eventMap: HashMap<Int?, List<Long>?> = HashMap<Int?, List<Long>?>()
+ val timeObj = Time()
+ var nextAlarmTime = Long.MAX_VALUE
+ var nextAlarmEventId = 0
+ instancesCursor.moveToPosition(-1)
+ while (!instancesCursor.isAfterLast()) {
+ var index = 0
+ eventMap.clear()
+ val eventIdsForQuery = StringBuilder()
+ eventIdsForQuery.append('(')
+ while (index++ < batchSize && instancesCursor.moveToNext()) {
+ val eventId: Int = instancesCursor.getInt(INSTANCES_INDEX_EVENTID)
+ val begin: Long = instancesCursor.getLong(INSTANCES_INDEX_BEGIN)
+ val allday = instancesCursor.getInt(INSTANCES_INDEX_ALL_DAY) != 0
+ var localStartTime: Long
+ localStartTime = if (allday) {
+ // Adjust allday to local time.
+ Utils.convertAlldayUtcToLocal(
+ timeObj, begin,
+ Time.getCurrentTimezone()
+ )
+ } else {
+ begin
+ }
+ var startTimes: List<Long>? = eventMap.get(eventId)
+ if (startTimes == null) {
+ startTimes = mutableListOf<Long>() as List<Long>
+ eventMap.put(eventId, startTimes)
+ eventIdsForQuery.append(eventId)
+ eventIdsForQuery.append(",")
+ }
+ startTimes.add(localStartTime)
+
+ // Log for debugging.
+ if (Log.isLoggable(TAG, Log.DEBUG)) {
+ timeObj.set(localStartTime)
+ val msg = StringBuilder()
+ msg.append("Events cursor result -- eventId:").append(eventId)
+ msg.append(", allDay:").append(allday)
+ msg.append(", start:").append(localStartTime)
+ msg.append(" (").append(timeObj.format("%a, %b %d, %Y %I:%M%P")).append(")")
+ Log.d(TAG, msg.toString())
+ }
+ }
+ if (eventIdsForQuery[eventIdsForQuery.length - 1] == ',') {
+ eventIdsForQuery.deleteCharAt(eventIdsForQuery.length - 1)
+ }
+ eventIdsForQuery.append(')')
+
+ // Query the reminders table for the events found.
+ var cursor: Cursor? = null
+ try {
+ cursor = contentResolver.query(
+ Reminders.CONTENT_URI, REMINDERS_PROJECTION,
+ REMINDERS_WHERE + eventIdsForQuery, null, null
+ )
+
+ // Process the reminders query results to find the next reminder time.
+ cursor?.moveToPosition(-1)
+ while (cursor!!.moveToNext()) {
+ val eventId: Int = cursor.getInt(REMINDERS_INDEX_EVENT_ID)
+ val reminderMinutes: Int = cursor.getInt(REMINDERS_INDEX_MINUTES)
+ val startTimes: List<Long>? = eventMap.get(eventId)
+ if (startTimes != null) {
+ for (startTime in startTimes) {
+ val alarmTime: Long = startTime -
+ reminderMinutes * DateUtils.MINUTE_IN_MILLIS
+ if (alarmTime > currentMillis && alarmTime < nextAlarmTime) {
+ nextAlarmTime = alarmTime
+ nextAlarmEventId = eventId
+ }
+ if (Log.isLoggable(TAG, Log.DEBUG)) {
+ timeObj.set(alarmTime)
+ val msg = StringBuilder()
+ msg.append("Reminders cursor result -- eventId:").append(eventId)
+ msg.append(", startTime:").append(startTime)
+ msg.append(", minutes:").append(reminderMinutes)
+ msg.append(", alarmTime:").append(alarmTime)
+ msg.append(" (").append(timeObj.format("%a, %b %d, %Y %I:%M%P"))
+ .append(")")
+ Log.d(TAG, msg.toString())
+ }
+ }
+ }
+ }
+ } finally {
+ if (cursor != null) {
+ cursor.close()
+ }
+ }
+ }
+
+ // Schedule the alarm for the next reminder time.
+ if (nextAlarmTime < Long.MAX_VALUE) {
+ scheduleAlarm(
+ context,
+ nextAlarmEventId.toLong(),
+ nextAlarmTime,
+ currentMillis,
+ alarmManager
+ )
+ }
+ }
+
+ /**
+ * Schedules an alarm for the EVENT_REMINDER_APP broadcast, for the specified
+ * alarm time with a slight delay (to account for the possible duplicate broadcast
+ * from the provider).
+ */
+ @JvmStatic private fun scheduleAlarm(
+ context: Context,
+ eventId: Long,
+ alarmTimeInput: Long,
+ currentMillis: Long,
+ alarmManager: AlarmManagerInterface
+ ) {
+ // Max out the alarm time to 1 day out, so an alert for an event far in the future
+ // (not present in our event query results for a limited range) can only be at
+ // most 1 day late.
+ var alarmTime = alarmTimeInput
+ val maxAlarmTime = currentMillis + MAX_ALARM_ELAPSED_MS
+ if (alarmTime > maxAlarmTime) {
+ alarmTime = maxAlarmTime
+ }
+
+ // Add a slight delay (see comments on the member var).
+ alarmTime += ALARM_DELAY_MS.toLong()
+ if (AlertService.DEBUG) {
+ val time = Time()
+ time.set(alarmTime)
+ val schedTime: String = time.format("%a, %b %d, %Y %I:%M%P")
+ Log.d(
+ TAG, "Scheduling alarm for EVENT_REMINDER_APP broadcast for event " + eventId +
+ " at " + alarmTime + " (" + schedTime + ")"
+ )
+ }
+
+ // Schedule an EVENT_REMINDER_APP broadcast with AlarmManager. The extra is
+ // only used by AlertService for logging. It is ignored by Intent.filterEquals,
+ // so this scheduling will still overwrite the alarm that was previously pending.
+ // Note that the 'setClass' is required, because otherwise it seems the broadcast
+ // can be eaten by other apps and we somehow may never receive it.
+ val intent = Intent(AlertReceiver.EVENT_REMINDER_APP_ACTION)
+ intent.setClass(context, AlertReceiver::class.java)
+ intent.putExtra(CalendarContract.CalendarAlerts.ALARM_TIME, alarmTime)
+ val pi: PendingIntent = PendingIntent.getBroadcast(context, 0, intent, 0)
+ alarmManager.set(AlarmManager.RTC_WAKEUP, alarmTime, pi)
+ }
+} \ No newline at end of file