1 /* 2 * Copyright (C) 2021 The Android Open Source Project 3 * 4 * Licensed under the Apache License, Version 2.0 (the "License"); 5 * you may not use this file except in compliance with the License. 6 * You may obtain a copy of the License at 7 * 8 * http://www.apache.org/licenses/LICENSE-2.0 9 * 10 * Unless required by applicable law or agreed to in writing, software 11 * distributed under the License is distributed on an "AS IS" BASIS, 12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 * See the License for the specific language governing permissions and 14 * limitations under the License. 15 */ 16 package com.android.calendar.alerts 17 18 import android.app.AlarmManager 19 import android.app.PendingIntent 20 import android.content.ContentResolver 21 import android.content.ContentUris 22 import android.content.Context 23 import android.content.Intent 24 import android.database.Cursor 25 import android.net.Uri 26 import android.provider.CalendarContract 27 import android.provider.CalendarContract.Events 28 import android.provider.CalendarContract.Instances 29 import android.provider.CalendarContract.Reminders 30 import android.text.format.DateUtils 31 import android.text.format.Time 32 import android.util.Log 33 import com.android.calendar.Utils 34 import java.util.HashMap 35 import java.util.List 36 37 /** 38 * Schedules the next EVENT_REMINDER_APP broadcast with AlarmManager, by querying the events 39 * and reminders tables for the next upcoming alert. 40 */ 41 object AlarmScheduler { 42 private const val TAG = "AlarmScheduler" 43 private val INSTANCES_WHERE: String = (Events.VISIBLE.toString() + "=? AND " + 44 Instances.BEGIN + ">=? AND " + Instances.BEGIN + "<=? AND " + 45 Events.ALL_DAY + "=?") 46 val INSTANCES_PROJECTION = arrayOf<String>( 47 Instances.EVENT_ID, 48 Instances.BEGIN, 49 Instances.ALL_DAY 50 ) 51 private const val INSTANCES_INDEX_EVENTID = 0 52 private const val INSTANCES_INDEX_BEGIN = 1 53 private const val INSTANCES_INDEX_ALL_DAY = 2 54 private val REMINDERS_WHERE: String = (Reminders.METHOD.toString() + "=1 AND " + 55 Reminders.EVENT_ID + " IN ") 56 val REMINDERS_PROJECTION = arrayOf<String>( 57 Reminders.EVENT_ID, 58 Reminders.MINUTES, 59 Reminders.METHOD 60 ) 61 private const val REMINDERS_INDEX_EVENT_ID = 0 62 private const val REMINDERS_INDEX_MINUTES = 1 63 private const val REMINDERS_INDEX_METHOD = 2 64 65 // Add a slight delay for the EVENT_REMINDER_APP broadcast for a couple reasons: 66 // (1) so that the concurrent reminder broadcast from the provider doesn't result 67 // in a double ring, and (2) some OEMs modified the provider to not add an alert to 68 // the CalendarAlerts table until the alert time, so for the unbundled app's 69 // notifications to work on these devices, a delay ensures that AlertService won't 70 // read from the CalendarAlerts table until the alert is present. 71 const val ALARM_DELAY_MS = 1000 72 73 // The reminders query looks like "SELECT ... AND eventId IN 101,102,202,...". This 74 // sets the max # of events in the query before batching into multiple queries, to 75 // limit the SQL query length. 76 private const val REMINDER_QUERY_BATCH_SIZE = 50 77 78 // We really need to query for reminder times that fall in some interval, but 79 // the Reminders table only stores the reminder interval (10min, 15min, etc), and 80 // we cannot do the join with the Events table to calculate the actual alert time 81 // from outside of the provider. So the best we can do for now consider events 82 // whose start times begin within some interval (ie. 1 week out). This means 83 // reminders which are configured for more than 1 week out won't fire on time. We 84 // can minimize this to being only 1 day late by putting a 1 day max on the alarm time. 85 private val EVENT_LOOKAHEAD_WINDOW_MS: Long = DateUtils.WEEK_IN_MILLIS 86 private val MAX_ALARM_ELAPSED_MS: Long = DateUtils.DAY_IN_MILLIS 87 88 /** 89 * Schedules the nearest upcoming alarm, to refresh notifications. 90 * 91 * This is historically done in the provider but we dupe this here so the unbundled 92 * app will work on devices that have modified this portion of the provider. This 93 * has the limitation of querying events within some interval from now (ie. looks at 94 * reminders for all events occurring in the next week). This means for example, 95 * a 2 week notification will not fire on time. 96 */ scheduleNextAlarmnull97 @JvmStatic fun scheduleNextAlarm(context: Context) { 98 scheduleNextAlarm( 99 context, AlertUtils.createAlarmManager(context), 100 REMINDER_QUERY_BATCH_SIZE, System.currentTimeMillis() 101 ) 102 } 103 104 // VisibleForTesting scheduleNextAlarmnull105 @JvmStatic fun scheduleNextAlarm( 106 context: Context, 107 alarmManager: AlarmManagerInterface?, 108 batchSize: Int, 109 currentMillis: Long 110 ) { 111 var instancesCursor: Cursor? = null 112 try { 113 instancesCursor = queryUpcomingEvents( 114 context, context.getContentResolver(), 115 currentMillis 116 ) 117 if (instancesCursor != null) { 118 queryNextReminderAndSchedule( 119 instancesCursor, 120 context, 121 context.getContentResolver(), 122 alarmManager as AlarmManagerInterface, 123 batchSize, 124 currentMillis 125 ) 126 } 127 } finally { 128 if (instancesCursor != null) { 129 instancesCursor.close() 130 } 131 } 132 } 133 134 /** 135 * Queries events starting within a fixed interval from now. 136 */ queryUpcomingEventsnull137 @JvmStatic private fun queryUpcomingEvents( 138 context: Context, 139 contentResolver: ContentResolver, 140 currentMillis: Long 141 ): Cursor? { 142 val time = Time() 143 time.normalize(false) 144 val localOffset: Long = time.gmtoff * 1000 145 val localStartMax = 146 currentMillis + EVENT_LOOKAHEAD_WINDOW_MS 147 val utcStartMin = currentMillis - localOffset 148 val utcStartMax = 149 utcStartMin + EVENT_LOOKAHEAD_WINDOW_MS 150 151 // Expand Instances table range by a day on either end to account for 152 // all-day events. 153 val uriBuilder: Uri.Builder = Instances.CONTENT_URI.buildUpon() 154 ContentUris.appendId(uriBuilder, currentMillis - DateUtils.DAY_IN_MILLIS) 155 ContentUris.appendId(uriBuilder, localStartMax + DateUtils.DAY_IN_MILLIS) 156 157 // Build query for all events starting within the fixed interval. 158 val queryBuilder = StringBuilder() 159 queryBuilder.append("(") 160 queryBuilder.append(INSTANCES_WHERE) 161 queryBuilder.append(") OR (") 162 queryBuilder.append(INSTANCES_WHERE) 163 queryBuilder.append(")") 164 val queryArgs = arrayOf( 165 // allday selection 166 "1", /* visible = ? */ 167 utcStartMin.toString(), /* begin >= ? */ 168 utcStartMax.toString(), /* begin <= ? */ 169 "1", /* allDay = ? */ // non-allday selection 170 "1", /* visible = ? */ 171 currentMillis.toString(), /* begin >= ? */ 172 localStartMax.toString(), /* begin <= ? */ 173 "0" /* allDay = ? */ 174 ) 175 176 val cursor: Cursor? = contentResolver.query(uriBuilder.build(), INSTANCES_PROJECTION, 177 queryBuilder.toString(), queryArgs, null) 178 return cursor 179 } 180 181 /** 182 * Queries for all the reminders of the events in the instancesCursor, and schedules 183 * the alarm for the next upcoming reminder. 184 */ queryNextReminderAndSchedulenull185 @JvmStatic private fun queryNextReminderAndSchedule( 186 instancesCursor: Cursor, 187 context: Context, 188 contentResolver: ContentResolver, 189 alarmManager: AlarmManagerInterface, 190 batchSize: Int, 191 currentMillis: Long 192 ) { 193 if (AlertService.DEBUG) { 194 val eventCount: Int = instancesCursor.getCount() 195 if (eventCount == 0) { 196 Log.d(TAG, "No events found starting within 1 week.") 197 } else { 198 Log.d(TAG, "Query result count for events starting within 1 week: $eventCount") 199 } 200 } 201 202 // Put query results of all events starting within some interval into map of event ID to 203 // local start time. 204 val eventMap: HashMap<Int?, List<Long>?> = HashMap<Int?, List<Long>?>() 205 val timeObj = Time() 206 var nextAlarmTime = Long.MAX_VALUE 207 var nextAlarmEventId = 0 208 instancesCursor.moveToPosition(-1) 209 while (!instancesCursor.isAfterLast()) { 210 var index = 0 211 eventMap.clear() 212 val eventIdsForQuery = StringBuilder() 213 eventIdsForQuery.append('(') 214 while (index++ < batchSize && instancesCursor.moveToNext()) { 215 val eventId: Int = instancesCursor.getInt(INSTANCES_INDEX_EVENTID) 216 val begin: Long = instancesCursor.getLong(INSTANCES_INDEX_BEGIN) 217 val allday = instancesCursor.getInt(INSTANCES_INDEX_ALL_DAY) != 0 218 var localStartTime: Long 219 localStartTime = if (allday) { 220 // Adjust allday to local time. 221 Utils.convertAlldayUtcToLocal( 222 timeObj, begin, 223 Time.getCurrentTimezone() 224 ) 225 } else { 226 begin 227 } 228 var startTimes: List<Long>? = eventMap.get(eventId) 229 if (startTimes == null) { 230 startTimes = mutableListOf<Long>() as List<Long> 231 eventMap.put(eventId, startTimes) 232 eventIdsForQuery.append(eventId) 233 eventIdsForQuery.append(",") 234 } 235 startTimes.add(localStartTime) 236 237 // Log for debugging. 238 if (Log.isLoggable(TAG, Log.DEBUG)) { 239 timeObj.set(localStartTime) 240 val msg = StringBuilder() 241 msg.append("Events cursor result -- eventId:").append(eventId) 242 msg.append(", allDay:").append(allday) 243 msg.append(", start:").append(localStartTime) 244 msg.append(" (").append(timeObj.format("%a, %b %d, %Y %I:%M%P")).append(")") 245 Log.d(TAG, msg.toString()) 246 } 247 } 248 if (eventIdsForQuery[eventIdsForQuery.length - 1] == ',') { 249 eventIdsForQuery.deleteCharAt(eventIdsForQuery.length - 1) 250 } 251 eventIdsForQuery.append(')') 252 253 // Query the reminders table for the events found. 254 var cursor: Cursor? = null 255 try { 256 cursor = contentResolver.query( 257 Reminders.CONTENT_URI, REMINDERS_PROJECTION, 258 REMINDERS_WHERE + eventIdsForQuery, null, null 259 ) 260 261 // Process the reminders query results to find the next reminder time. 262 cursor?.moveToPosition(-1) 263 while (cursor!!.moveToNext()) { 264 val eventId: Int = cursor.getInt(REMINDERS_INDEX_EVENT_ID) 265 val reminderMinutes: Int = cursor.getInt(REMINDERS_INDEX_MINUTES) 266 val startTimes: List<Long>? = eventMap.get(eventId) 267 if (startTimes != null) { 268 for (startTime in startTimes) { 269 val alarmTime: Long = startTime - 270 reminderMinutes * DateUtils.MINUTE_IN_MILLIS 271 if (alarmTime > currentMillis && alarmTime < nextAlarmTime) { 272 nextAlarmTime = alarmTime 273 nextAlarmEventId = eventId 274 } 275 if (Log.isLoggable(TAG, Log.DEBUG)) { 276 timeObj.set(alarmTime) 277 val msg = StringBuilder() 278 msg.append("Reminders cursor result -- eventId:").append(eventId) 279 msg.append(", startTime:").append(startTime) 280 msg.append(", minutes:").append(reminderMinutes) 281 msg.append(", alarmTime:").append(alarmTime) 282 msg.append(" (").append(timeObj.format("%a, %b %d, %Y %I:%M%P")) 283 .append(")") 284 Log.d(TAG, msg.toString()) 285 } 286 } 287 } 288 } 289 } finally { 290 if (cursor != null) { 291 cursor.close() 292 } 293 } 294 } 295 296 // Schedule the alarm for the next reminder time. 297 if (nextAlarmTime < Long.MAX_VALUE) { 298 scheduleAlarm( 299 context, 300 nextAlarmEventId.toLong(), 301 nextAlarmTime, 302 currentMillis, 303 alarmManager 304 ) 305 } 306 } 307 308 /** 309 * Schedules an alarm for the EVENT_REMINDER_APP broadcast, for the specified 310 * alarm time with a slight delay (to account for the possible duplicate broadcast 311 * from the provider). 312 */ scheduleAlarmnull313 @JvmStatic private fun scheduleAlarm( 314 context: Context, 315 eventId: Long, 316 alarmTimeInput: Long, 317 currentMillis: Long, 318 alarmManager: AlarmManagerInterface 319 ) { 320 // Max out the alarm time to 1 day out, so an alert for an event far in the future 321 // (not present in our event query results for a limited range) can only be at 322 // most 1 day late. 323 var alarmTime = alarmTimeInput 324 val maxAlarmTime = currentMillis + MAX_ALARM_ELAPSED_MS 325 if (alarmTime > maxAlarmTime) { 326 alarmTime = maxAlarmTime 327 } 328 329 // Add a slight delay (see comments on the member var). 330 alarmTime += ALARM_DELAY_MS.toLong() 331 if (AlertService.DEBUG) { 332 val time = Time() 333 time.set(alarmTime) 334 val schedTime: String = time.format("%a, %b %d, %Y %I:%M%P") 335 Log.d( 336 TAG, "Scheduling alarm for EVENT_REMINDER_APP broadcast for event " + eventId + 337 " at " + alarmTime + " (" + schedTime + ")" 338 ) 339 } 340 341 // Schedule an EVENT_REMINDER_APP broadcast with AlarmManager. The extra is 342 // only used by AlertService for logging. It is ignored by Intent.filterEquals, 343 // so this scheduling will still overwrite the alarm that was previously pending. 344 // Note that the 'setClass' is required, because otherwise it seems the broadcast 345 // can be eaten by other apps and we somehow may never receive it. 346 val intent = Intent(AlertReceiver.EVENT_REMINDER_APP_ACTION) 347 intent.setClass(context, AlertReceiver::class.java) 348 intent.putExtra(CalendarContract.CalendarAlerts.ALARM_TIME, alarmTime) 349 val pi: PendingIntent = PendingIntent.getBroadcast(context, 0, intent, 0) 350 alarmManager.set(AlarmManager.RTC_WAKEUP, alarmTime, pi) 351 } 352 }