diff options
Diffstat (limited to 'src/com/android/calendar/alerts/AlarmScheduler.kt')
-rw-r--r-- | src/com/android/calendar/alerts/AlarmScheduler.kt | 352 |
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 |