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 }