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 }