blob: c93bbb0496d45e39a4d2471d74705d03ac8f183e [file] [log] [blame]
/*
* 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)
}
}