summaryrefslogtreecommitdiff
path: root/src/com/android/calendar/alerts/AlarmScheduler.kt
blob: c93bbb0496d45e39a4d2471d74705d03ac8f183e (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
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)
    }
}