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
17 
18 import android.content.ContentResolver
19 import android.content.ContentUris
20 import android.content.Context
21 import android.content.SharedPreferences
22 import android.content.res.Resources
23 import android.database.Cursor
24 import android.net.Uri
25 import android.os.Debug
26 import android.provider.CalendarContract.Attendees
27 import android.provider.CalendarContract.Calendars
28 import android.provider.CalendarContract.Events
29 import android.provider.CalendarContract.Instances
30 import android.text.TextUtils
31 import android.text.format.DateUtils
32 import android.util.Log
33 
34 import java.util.ArrayList
35 import java.util.Arrays
36 import java.util.Iterator
37 import java.util.concurrent.atomic.AtomicInteger
38 
39 // TODO: should Event be Parcelable so it can be passed via Intents?
40 class Event : Cloneable {
41     companion object {
42         private const val TAG = "CalEvent"
43         private const val PROFILE = false
44 
45         /**
46          * The sort order is:
47          * 1) events with an earlier start (begin for normal events, startday for allday)
48          * 2) events with a later end (end for normal events, endday for allday)
49          * 3) the title (unnecessary, but nice)
50          *
51          * The start and end day is sorted first so that all day events are
52          * sorted correctly with respect to events that are >24 hours (and
53          * therefore show up in the allday area).
54          */
55         private const val SORT_EVENTS_BY = "begin ASC, end DESC, title ASC"
56         private const val SORT_ALLDAY_BY = "startDay ASC, endDay DESC, title ASC"
57         private const val DISPLAY_AS_ALLDAY = "dispAllday"
58         private const val EVENTS_WHERE = DISPLAY_AS_ALLDAY + "=0"
59         private const val ALLDAY_WHERE = DISPLAY_AS_ALLDAY + "=1"
60 
61         // The projection to use when querying instances to build a list of events
62         @JvmField
63         val EVENT_PROJECTION = arrayOf<String>(
64             Instances.TITLE, // 0
65             Instances.EVENT_LOCATION, // 1
66             Instances.ALL_DAY, // 2
67             Instances.DISPLAY_COLOR, // 3 If SDK < 16, set to Instances.CALENDAR_COLOR.
68             Instances.EVENT_TIMEZONE, // 4
69             Instances.EVENT_ID, // 5
70             Instances.BEGIN, // 6
71             Instances.END,  // 7
72             Instances._ID,  // 8
73             Instances.START_DAY, // 9
74             Instances.END_DAY, // 10
75             Instances.START_MINUTE, // 11
76             Instances.END_MINUTE, // 12
77             Instances.HAS_ALARM, // 13
78             Instances.RRULE,  // 14
79             Instances.RDATE,  // 15
80             Instances.SELF_ATTENDEE_STATUS, // 16
81             Events.ORGANIZER, // 17
82             Events.GUESTS_CAN_MODIFY, // 18
83             Instances.ALL_DAY.toString() + "=1 OR (" + Instances.END + "-" +
84                 Instances.BEGIN + ")>=" +
85                 DateUtils.DAY_IN_MILLIS + " AS " + DISPLAY_AS_ALLDAY
86         )
87 
88         // The indices for the projection array above.
89         private const val PROJECTION_TITLE_INDEX = 0
90         private const val PROJECTION_LOCATION_INDEX = 1
91         private const val PROJECTION_ALL_DAY_INDEX = 2
92         private const val PROJECTION_COLOR_INDEX = 3
93         private const val PROJECTION_TIMEZONE_INDEX = 4
94         private const val PROJECTION_EVENT_ID_INDEX = 5
95         private const val PROJECTION_BEGIN_INDEX = 6
96         private const val PROJECTION_END_INDEX = 7
97         private const val PROJECTION_START_DAY_INDEX = 9
98         private const val PROJECTION_END_DAY_INDEX = 10
99         private const val PROJECTION_START_MINUTE_INDEX = 11
100         private const val PROJECTION_END_MINUTE_INDEX = 12
101         private const val PROJECTION_HAS_ALARM_INDEX = 13
102         private const val PROJECTION_RRULE_INDEX = 14
103         private const val PROJECTION_RDATE_INDEX = 15
104         private const val PROJECTION_SELF_ATTENDEE_STATUS_INDEX = 16
105         private const val PROJECTION_ORGANIZER_INDEX = 17
106         private const val PROJECTION_GUESTS_CAN_INVITE_OTHERS_INDEX = 18
107         private const val PROJECTION_DISPLAY_AS_ALLDAY = 19
108         private var mNoTitleString: String? = null
109         private var mNoColorColor = 0
newInstancenull110         @JvmStatic fun newInstance(): Event {
111             val e = Event()
112             e.id = 0
113             e.title = null
114             e.color = 0
115             e.location = null
116             e.allDay = false
117             e.startDay = 0
118             e.endDay = 0
119             e.startTime = 0
120             e.endTime = 0
121             e.startMillis = 0
122             e.endMillis = 0
123             e.hasAlarm = false
124             e.isRepeating = false
125             e.selfAttendeeStatus = Attendees.ATTENDEE_STATUS_NONE
126             return e
127         }
128 
129         /**
130          * Loads *days* days worth of instances starting at *startDay*.
131          */
loadEventsnull132         @JvmStatic fun loadEvents(
133             context: Context?,
134             events: ArrayList<Event?>,
135             startDay: Int,
136             days: Int,
137             requestId: Int,
138             sequenceNumber: AtomicInteger?
139         ) {
140             if (PROFILE) {
141                 Debug.startMethodTracing("loadEvents")
142             }
143             var cEvents: Cursor? = null
144             var cAllday: Cursor? = null
145             events.clear()
146             try {
147                 val endDay = startDay + days - 1
148 
149                 // We use the byDay instances query to get a list of all events for
150                 // the days we're interested in.
151                 // The sort order is: events with an earlier start time occur
152                 // first and if the start times are the same, then events with
153                 // a later end time occur first. The later end time is ordered
154                 // first so that long rectangles in the calendar views appear on
155                 // the left side.  If the start and end times of two events are
156                 // the same then we sort alphabetically on the title.  This isn't
157                 // required for correctness, it just adds a nice touch.
158 
159                 // Respect the preference to show/hide declined events
160                 val prefs: SharedPreferences? = GeneralPreferences.getSharedPreferences(context)
161                 val hideDeclined: Boolean = prefs?.getBoolean(
162                     GeneralPreferences.KEY_HIDE_DECLINED,
163                     false
164                 ) as Boolean
165                 var where = EVENTS_WHERE
166                 var whereAllday = ALLDAY_WHERE
167                 if (hideDeclined) {
168                     val hideString = (" AND " + Instances.SELF_ATTENDEE_STATUS.toString() + "!=" +
169                         Attendees.ATTENDEE_STATUS_DECLINED)
170                     where += hideString
171                     whereAllday += hideString
172                 }
173                 cEvents = instancesQuery(
174                     context?.getContentResolver(), EVENT_PROJECTION, startDay,
175                     endDay, where, null, SORT_EVENTS_BY
176                 )
177                 cAllday = instancesQuery(
178                     context?.getContentResolver(), EVENT_PROJECTION, startDay,
179                     endDay, whereAllday, null, SORT_ALLDAY_BY
180                 )
181 
182                 // Check if we should return early because there are more recent
183                 // load requests waiting.
184                 if (requestId != sequenceNumber?.get()) {
185                     return
186                 }
187                 buildEventsFromCursor(events, cEvents, context, startDay, endDay)
188                 buildEventsFromCursor(events, cAllday, context, startDay, endDay)
189             } finally {
190                 if (cEvents != null) {
191                     cEvents.close()
192                 }
193                 if (cAllday != null) {
194                     cAllday.close()
195                 }
196                 if (PROFILE) {
197                     Debug.stopMethodTracing()
198                 }
199             }
200         }
201 
202         /**
203          * Performs a query to return all visible instances in the given range
204          * that match the given selection. This is a blocking function and
205          * should not be done on the UI thread. This will cause an expansion of
206          * recurring events to fill this time range if they are not already
207          * expanded and will slow down for larger time ranges with many
208          * recurring events.
209          *
210          * @param cr The ContentResolver to use for the query
211          * @param projection The columns to return
212          * @param begin The start of the time range to query in UTC millis since
213          * epoch
214          * @param end The end of the time range to query in UTC millis since
215          * epoch
216          * @param selection Filter on the query as an SQL WHERE statement
217          * @param selectionArgs Args to replace any '?'s in the selection
218          * @param orderBy How to order the rows as an SQL ORDER BY statement
219          * @return A Cursor of instances matching the selection
220          */
instancesQuerynull221         @JvmStatic private fun instancesQuery(
222             cr: ContentResolver?,
223             projection: Array<String>,
224             startDay: Int,
225             endDay: Int,
226             selection: String,
227             selectionArgs: Array<String?>?,
228             orderBy: String?
229         ): Cursor? {
230             var selection = selection
231             var selectionArgs = selectionArgs
232             val WHERE_CALENDARS_SELECTED: String = Calendars.VISIBLE.toString() + "=?"
233             val WHERE_CALENDARS_ARGS = arrayOf<String?>("1")
234             val DEFAULT_SORT_ORDER = "begin ASC"
235             val builder: Uri.Builder = Instances.CONTENT_BY_DAY_URI.buildUpon()
236             ContentUris.appendId(builder, startDay.toLong())
237             ContentUris.appendId(builder, endDay.toLong())
238             if (TextUtils.isEmpty(selection)) {
239                 selection = WHERE_CALENDARS_SELECTED
240                 selectionArgs = WHERE_CALENDARS_ARGS
241             } else {
242                 selection = "($selection) AND $WHERE_CALENDARS_SELECTED"
243                 if (selectionArgs != null && selectionArgs.size > 0) {
244                     selectionArgs = Arrays.copyOf(selectionArgs, selectionArgs.size + 1)
245                     selectionArgs[selectionArgs.size - 1] = WHERE_CALENDARS_ARGS[0]
246                 } else {
247                     selectionArgs = WHERE_CALENDARS_ARGS
248                 }
249             }
250             return cr?.query(
251                 builder.build(), projection, selection, selectionArgs,
252                 orderBy ?: DEFAULT_SORT_ORDER
253             )
254         }
255 
256         /**
257          * Adds all the events from the cursors to the events list.
258          *
259          * @param events The list of events
260          * @param cEvents Events to add to the list
261          * @param context
262          * @param startDay
263          * @param endDay
264          */
buildEventsFromCursornull265         @JvmStatic fun buildEventsFromCursor(
266             events: ArrayList<Event?>?,
267             cEvents: Cursor?,
268             context: Context?,
269             startDay: Int,
270             endDay: Int
271         ) {
272             if (cEvents == null || events == null) {
273                 Log.e(TAG, "buildEventsFromCursor: null cursor or null events list!")
274                 return
275             }
276             val count: Int = cEvents.getCount()
277             if (count == 0) {
278                 return
279             }
280             val res: Resources? = context?.getResources()
281             mNoTitleString = res?.getString(R.string.no_title_label)
282             mNoColorColor = res?.getColor(R.color.event_center) as Int
283             // Sort events in two passes so we ensure the allday and standard events
284             // get sorted in the correct order
285             cEvents.moveToPosition(-1)
286             while (cEvents.moveToNext()) {
287                 val e = generateEventFromCursor(cEvents)
288                 if (e.startDay > endDay || e.endDay < startDay) {
289                     continue
290                 }
291                 events.add(e)
292             }
293         }
294 
295         /**
296          * @param cEvents Cursor pointing at event
297          * @return An event created from the cursor
298          */
generateEventFromCursornull299         @JvmStatic private fun generateEventFromCursor(cEvents: Cursor): Event {
300             val e = Event()
301             e.id = cEvents.getLong(PROJECTION_EVENT_ID_INDEX)
302             e.title = cEvents.getString(PROJECTION_TITLE_INDEX)
303             e.location = cEvents.getString(PROJECTION_LOCATION_INDEX)
304             e.allDay = cEvents.getInt(PROJECTION_ALL_DAY_INDEX) !== 0
305             e.organizer = cEvents.getString(PROJECTION_ORGANIZER_INDEX)
306             e.guestsCanModify = cEvents.getInt(PROJECTION_GUESTS_CAN_INVITE_OTHERS_INDEX) !== 0
307             if (e.title == null || e.title!!.length == 0) {
308                 e.title = mNoTitleString
309             }
310             if (!cEvents.isNull(PROJECTION_COLOR_INDEX)) {
311                 // Read the color from the database
312                 e.color = Utils.getDisplayColorFromColor(cEvents.getInt(PROJECTION_COLOR_INDEX))
313             } else {
314                 e.color = mNoColorColor
315             }
316             val eStart: Long = cEvents.getLong(PROJECTION_BEGIN_INDEX)
317             val eEnd: Long = cEvents.getLong(PROJECTION_END_INDEX)
318             e.startMillis = eStart
319             e.startTime = cEvents.getInt(PROJECTION_START_MINUTE_INDEX)
320             e.startDay = cEvents.getInt(PROJECTION_START_DAY_INDEX)
321             e.endMillis = eEnd
322             e.endTime = cEvents.getInt(PROJECTION_END_MINUTE_INDEX)
323             e.endDay = cEvents.getInt(PROJECTION_END_DAY_INDEX)
324             e.hasAlarm = cEvents.getInt(PROJECTION_HAS_ALARM_INDEX) !== 0
325 
326             // Check if this is a repeating event
327             val rrule: String = cEvents.getString(PROJECTION_RRULE_INDEX)
328             val rdate: String = cEvents.getString(PROJECTION_RDATE_INDEX)
329             if (!TextUtils.isEmpty(rrule) || !TextUtils.isEmpty(rdate)) {
330                 e.isRepeating = true
331             } else {
332                 e.isRepeating = false
333             }
334             e.selfAttendeeStatus = cEvents.getInt(PROJECTION_SELF_ATTENDEE_STATUS_INDEX)
335             return e
336         }
337 
338         /**
339          * Computes a position for each event.  Each event is displayed
340          * as a non-overlapping rectangle.  For normal events, these rectangles
341          * are displayed in separate columns in the week view and day view.  For
342          * all-day events, these rectangles are displayed in separate rows along
343          * the top.  In both cases, each event is assigned two numbers: N, and
344          * Max, that specify that this event is the Nth event of Max number of
345          * events that are displayed in a group. The width and position of each
346          * rectangle depend on the maximum number of rectangles that occur at
347          * the same time.
348          *
349          * @param eventsList the list of events, sorted into increasing time order
350          * @param minimumDurationMillis minimum duration acceptable as cell height of each event
351          * rectangle in millisecond. Should be 0 when it is not determined.
352          */
353         /* package */
computePositionsnull354         @JvmStatic fun computePositions(
355             eventsList: ArrayList<Event>?,
356             minimumDurationMillis: Long
357         ) {
358             if (eventsList == null) {
359                 return
360             }
361 
362             // Compute the column positions separately for the all-day events
363             doComputePositions(eventsList, minimumDurationMillis, false)
364             doComputePositions(eventsList, minimumDurationMillis, true)
365         }
366 
doComputePositionsnull367         @JvmStatic private fun doComputePositions(
368             eventsList: ArrayList<Event>,
369             minimumDurationMillis: Long,
370             doAlldayEvents: Boolean
371         ) {
372             var minimumDurationMillis = minimumDurationMillis
373             val activeList: ArrayList<Event> = ArrayList<Event>()
374             val groupList: ArrayList<Event> = ArrayList<Event>()
375             if (minimumDurationMillis < 0) {
376                 minimumDurationMillis = 0
377             }
378             var colMask: Long = 0
379             var maxCols = 0
380             for (event in eventsList) {
381                 // Process all-day events separately
382                 if (event.drawAsAllday() != doAlldayEvents) continue
383                 colMask = if (!doAlldayEvents) {
384                     removeNonAlldayActiveEvents(
385                         event, activeList.iterator() as Iterator<Event>,
386                         minimumDurationMillis, colMask
387                     )
388                 } else {
389                     removeAlldayActiveEvents(event, activeList.iterator()
390                         as Iterator<Event>, colMask)
391                 }
392 
393                 // If the active list is empty, then reset the max columns, clear
394                 // the column bit mask, and empty the groupList.
395                 if (activeList.isEmpty()) {
396                     for (ev in groupList) {
397                         ev.maxColumns = maxCols
398                     }
399                     maxCols = 0
400                     colMask = 0
401                     groupList.clear()
402                 }
403 
404                 // Find the first empty column.  Empty columns are represented by
405                 // zero bits in the column mask "colMask".
406                 var col = findFirstZeroBit(colMask)
407                 if (col == 64) col = 63
408                 colMask = colMask or (1L shl col)
409                 event.column = col
410                 activeList.add(event)
411                 groupList.add(event)
412                 val len: Int = activeList.size
413                 if (maxCols < len) maxCols = len
414             }
415             for (ev in groupList) {
416                 ev.maxColumns = maxCols
417             }
418         }
419 
removeAlldayActiveEventsnull420         @JvmStatic private fun removeAlldayActiveEvents(
421             event: Event,
422             iter: Iterator<Event>,
423             colMask: Long
424         ): Long {
425             // Remove the inactive allday events. An event on the active list
426             // becomes inactive when the end day is less than the current event's
427             // start day.
428             var colMask = colMask
429             while (iter.hasNext()) {
430                 val active = iter.next()
431                 if (active.endDay < event.startDay) {
432                     colMask = colMask and (1L shl active.column).inv()
433                     iter.remove()
434                 }
435             }
436             return colMask
437         }
438 
removeNonAlldayActiveEventsnull439         @JvmStatic private fun removeNonAlldayActiveEvents(
440             event: Event,
441             iter: Iterator<Event>,
442             minDurationMillis: Long,
443             colMask: Long
444         ): Long {
445             var colMask = colMask
446             val start = event.getStartMillis()
447             // Remove the inactive events. An event on the active list
448             // becomes inactive when its end time is less than or equal to
449             // the current event's start time.
450             while (iter.hasNext()) {
451                 val active = iter.next()
452                 val duration: Long = Math.max(
453                     active.getEndMillis() - active.getStartMillis(), minDurationMillis
454                 )
455                 if (active.getStartMillis() + duration <= start) {
456                     colMask = colMask and (1L shl active.column).inv()
457                     iter.remove()
458                 }
459             }
460             return colMask
461         }
462 
findFirstZeroBitnull463         @JvmStatic fun findFirstZeroBit(`val`: Long): Int {
464             for (ii in 0..63) {
465                 if (`val` and (1L shl ii) == 0L) return ii
466             }
467             return 64
468         }
469 
470         init {
471             if (!Utils.isJellybeanOrLater()) {
472                 EVENT_PROJECTION[PROJECTION_COLOR_INDEX] = Instances.CALENDAR_COLOR
473             }
474         }
475     }
476 
477     @JvmField var id: Long = 0
478     @JvmField var color = 0
479     @JvmField var title: CharSequence? = null
480     @JvmField var location: CharSequence? = null
481     @JvmField var allDay = false
482     @JvmField var organizer: String? = null
483     @JvmField var guestsCanModify = false
484     @JvmField var startDay = 0 // start Julian day
485     @JvmField var endDay = 0 // end Julian day
486     @JvmField var startTime = 0 // Start and end time are in minutes since midnight
487     @JvmField var endTime = 0
488     @JvmField var startMillis = 0L // UTC milliseconds since the epoch
489     @JvmField var endMillis = 0L // UTC milliseconds since the epoch
490     @JvmField var column = 0
491     @JvmField var maxColumns = 0
492     @JvmField var hasAlarm = false
493     @JvmField var isRepeating = false
494     @JvmField var selfAttendeeStatus = 0
495 
496     // The coordinates of the event rectangle drawn on the screen.
497     @JvmField var left = 0f
498     @JvmField var right = 0f
499     @JvmField var top = 0f
500     @JvmField var bottom = 0f
501 
502     // These 4 fields are used for navigating among events within the selected
503     // hour in the Day and Week view.
504     @JvmField var nextRight: Event? = null
505     @JvmField var nextLeft: Event? = null
506     @JvmField var nextUp: Event? = null
507     @JvmField var nextDown: Event? = null
508     @Override
509     @Throws(CloneNotSupportedException::class)
clonenull510     override fun clone(): Object {
511         super.clone()
512         val e = Event()
513         e.title = title
514         e.color = color
515         e.location = location
516         e.allDay = allDay
517         e.startDay = startDay
518         e.endDay = endDay
519         e.startTime = startTime
520         e.endTime = endTime
521         e.startMillis = startMillis
522         e.endMillis = endMillis
523         e.hasAlarm = hasAlarm
524         e.isRepeating = isRepeating
525         e.selfAttendeeStatus = selfAttendeeStatus
526         e.organizer = organizer
527         e.guestsCanModify = guestsCanModify
528         return e as Object
529     }
530 
copyTonull531     fun copyTo(dest: Event) {
532         dest.id = id
533         dest.title = title
534         dest.color = color
535         dest.location = location
536         dest.allDay = allDay
537         dest.startDay = startDay
538         dest.endDay = endDay
539         dest.startTime = startTime
540         dest.endTime = endTime
541         dest.startMillis = startMillis
542         dest.endMillis = endMillis
543         dest.hasAlarm = hasAlarm
544         dest.isRepeating = isRepeating
545         dest.selfAttendeeStatus = selfAttendeeStatus
546         dest.organizer = organizer
547         dest.guestsCanModify = guestsCanModify
548     }
549 
dumpnull550     fun dump() {
551         Log.e("Cal", "+-----------------------------------------+")
552         Log.e("Cal", "+        id = $id")
553         Log.e("Cal", "+     color = $color")
554         Log.e("Cal", "+     title = $title")
555         Log.e("Cal", "+  location = $location")
556         Log.e("Cal", "+    allDay = $allDay")
557         Log.e("Cal", "+  startDay = $startDay")
558         Log.e("Cal", "+    endDay = $endDay")
559         Log.e("Cal", "+ startTime = $startTime")
560         Log.e("Cal", "+   endTime = $endTime")
561         Log.e("Cal", "+ organizer = $organizer")
562         Log.e("Cal", "+  guestwrt = $guestsCanModify")
563     }
564 
intersectsnull565     fun intersects(
566         julianDay: Int,
567         startMinute: Int,
568         endMinute: Int
569     ): Boolean {
570         if (endDay < julianDay) {
571             return false
572         }
573         if (startDay > julianDay) {
574             return false
575         }
576         if (endDay == julianDay) {
577             if (endTime < startMinute) {
578                 return false
579             }
580             // An event that ends at the start minute should not be considered
581             // as intersecting the given time span, but don't exclude
582             // zero-length (or very short) events.
583             if (endTime == startMinute &&
584                 (startTime != endTime || startDay != endDay)) {
585                 return false
586             }
587         }
588         return !(startDay == julianDay && startTime > endMinute)
589     }
590 
591     /**
592      * Returns the event title and location separated by a comma.  If the
593      * location is already part of the title (at the end of the title), then
594      * just the title is returned.
595      *
596      * @return the event title and location as a String
597      */
598     val titleAndLocation: String
599         get() {
600             var text = title.toString()
601 
602             // Append the location to the title, unless the title ends with the
603             // location (for example, "meeting in building 42" ends with the
604             // location).
605             if (location != null) {
606                 val locationString = location.toString()
607                 if (!text.endsWith(locationString)) {
608                     text += ", $locationString"
609                 }
610             }
611             return text
612         }
613 
614     // TODO(damianpatel): this getter will likely not be
615     // needed once DayView.java is converted
getColumnnull616     fun getColumn(): Int {
617         return column
618     }
619 
setStartMillisnull620     fun setStartMillis(startMillis: Long) {
621         this.startMillis = startMillis
622     }
623 
getStartMillisnull624     fun getStartMillis(): Long {
625         return startMillis
626     }
627 
setEndMillisnull628     fun setEndMillis(endMillis: Long) {
629         this.endMillis = endMillis
630     }
631 
getEndMillisnull632     fun getEndMillis(): Long {
633         return endMillis
634     }
635 
drawAsAlldaynull636     fun drawAsAllday(): Boolean {
637         // Use >= so we'll pick up Exchange allday events
638         return allDay || endMillis - startMillis >= DateUtils.DAY_IN_MILLIS
639     }
640 }