1 /* 2 * Copyright 2020 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 17 package com.android.car.calendar.common; 18 19 import static com.google.common.base.Preconditions.checkState; 20 21 import static java.time.temporal.ChronoUnit.DAYS; 22 import static java.time.temporal.ChronoUnit.MINUTES; 23 24 import android.content.ContentResolver; 25 import android.content.ContentUris; 26 import android.database.ContentObserver; 27 import android.database.Cursor; 28 import android.net.Uri; 29 import android.os.Handler; 30 import android.provider.CalendarContract; 31 import android.provider.CalendarContract.Instances; 32 import android.util.Log; 33 34 import androidx.lifecycle.LiveData; 35 36 import com.google.common.collect.ImmutableList; 37 import com.google.common.collect.Iterables; 38 39 import java.time.Clock; 40 import java.time.Instant; 41 import java.time.ZoneId; 42 import java.time.ZonedDateTime; 43 import java.time.temporal.ChronoUnit; 44 import java.util.ArrayList; 45 import java.util.Comparator; 46 import java.util.List; 47 48 import javax.annotation.Nullable; 49 50 /** 51 * An observable source of calendar events coming from the <a 52 * href="https://developer.android.com/guide/topics/providers/calendar-provider">Calendar 53 * Provider</a>. 54 * 55 * <p>While in the active state the content provider is observed for changes. 56 */ 57 public class EventsLiveData extends LiveData<ImmutableList<Event>> { 58 59 private static final String TAG = "CarCalendarEventsLiveData"; 60 private static final boolean DEBUG = Log.isLoggable(TAG, Log.DEBUG); 61 62 // Sort events by start date and title. 63 private static final Comparator<Event> EVENT_COMPARATOR = 64 Comparator.comparing(Event::getDayStartInstant).thenComparing(Event::getTitle); 65 66 private final Clock mClock; 67 private final Handler mBackgroundHandler; 68 private final ContentResolver mContentResolver; 69 private final EventDescriptions mEventDescriptions; 70 private final EventLocations mLocations; 71 72 /** The event instances cursor is a field to allow observers to be managed. */ 73 @Nullable private Cursor mEventsCursor; 74 75 @Nullable private ContentObserver mEventInstancesObserver; 76 EventsLiveData( Clock clock, Handler backgroundHandler, ContentResolver contentResolver, EventDescriptions eventDescriptions, EventLocations locations)77 public EventsLiveData( 78 Clock clock, 79 Handler backgroundHandler, 80 ContentResolver contentResolver, 81 EventDescriptions eventDescriptions, 82 EventLocations locations) { 83 super(ImmutableList.of()); 84 mClock = clock; 85 mBackgroundHandler = backgroundHandler; 86 mContentResolver = contentResolver; 87 mEventDescriptions = eventDescriptions; 88 mLocations = locations; 89 } 90 91 /** Refreshes the event instances and sets the new value which notifies observers. */ update()92 private void update() { 93 postValue(getEventsUntilTomorrow()); 94 } 95 96 /** Queries the content provider for event instances. */ 97 @Nullable getEventsUntilTomorrow()98 private ImmutableList<Event> getEventsUntilTomorrow() { 99 // Check we are running on our background thread. 100 checkState(mBackgroundHandler.getLooper().isCurrentThread()); 101 102 if (mEventsCursor != null) { 103 tearDownCursor(); 104 } 105 106 ZonedDateTime now = ZonedDateTime.now(mClock); 107 108 // Find all events in the current day to include any all-day events. 109 ZonedDateTime startDateTime = now.truncatedTo(DAYS); 110 ZonedDateTime endDateTime = startDateTime.plusDays(2).truncatedTo(ChronoUnit.DAYS); 111 112 // Always create the cursor so we can observe it for changes to events. 113 mEventsCursor = createEventsCursor(startDateTime, endDateTime); 114 115 // If there are no calendars we return null 116 if (!hasCalendars()) { 117 return null; 118 } 119 120 List<Event> events = new ArrayList<>(); 121 while (mEventsCursor.moveToNext()) { 122 List<Event> eventsForRow = createEventsForRow(mEventsCursor, mEventDescriptions); 123 for (Event event : eventsForRow) { 124 // Filter out any events that do not overlap the time window. 125 if (event.getDayEndInstant().isBefore(now.toInstant()) 126 || !event.getDayStartInstant().isBefore(endDateTime.toInstant())) { 127 continue; 128 } 129 events.add(event); 130 } 131 } 132 events.sort(EVENT_COMPARATOR); 133 return ImmutableList.copyOf(events); 134 } 135 hasCalendars()136 private boolean hasCalendars() { 137 try (Cursor cursor = 138 mContentResolver.query(CalendarContract.Calendars.CONTENT_URI, null, null, null)) { 139 return cursor == null || cursor.getCount() > 0; 140 } 141 } 142 143 /** Creates a new {@link Cursor} over event instances with an updated time range. */ createEventsCursor(ZonedDateTime startDateTime, ZonedDateTime endDateTime)144 private Cursor createEventsCursor(ZonedDateTime startDateTime, ZonedDateTime endDateTime) { 145 Uri.Builder eventInstanceUriBuilder = CalendarContract.Instances.CONTENT_URI.buildUpon(); 146 if (DEBUG) Log.d(TAG, "Reading from " + startDateTime + " to " + endDateTime); 147 148 ContentUris.appendId(eventInstanceUriBuilder, startDateTime.toInstant().toEpochMilli()); 149 ContentUris.appendId(eventInstanceUriBuilder, endDateTime.toInstant().toEpochMilli()); 150 Uri eventInstanceUri = eventInstanceUriBuilder.build(); 151 Cursor cursor = 152 mContentResolver.query( 153 eventInstanceUri, 154 /* projection= */ null, 155 /* selection= */ null, 156 /* selectionArgs= */ null, 157 Instances.BEGIN); 158 159 // Set an observer on the Cursor, not the ContentResolver so it can be mocked for tests. 160 mEventInstancesObserver = 161 new ContentObserver(mBackgroundHandler) { 162 @Override 163 public boolean deliverSelfNotifications() { 164 return true; 165 } 166 167 @Override 168 public void onChange(boolean selfChange) { 169 if (DEBUG) Log.d(TAG, "Events changed"); 170 update(); 171 } 172 }; 173 cursor.setNotificationUri(mContentResolver, eventInstanceUri); 174 cursor.registerContentObserver(mEventInstancesObserver); 175 176 return cursor; 177 } 178 179 /** Can return multiple events for a single cursor row when an event spans multiple days. */ createEventsForRow( Cursor eventInstancesCursor, EventDescriptions eventDescriptions)180 private List<Event> createEventsForRow( 181 Cursor eventInstancesCursor, EventDescriptions eventDescriptions) { 182 String titleText = text(eventInstancesCursor, Instances.TITLE); 183 184 boolean allDay = integer(eventInstancesCursor, CalendarContract.Events.ALL_DAY) == 1; 185 String descriptionText = text(eventInstancesCursor, Instances.DESCRIPTION); 186 187 long startTimeMs = integer(eventInstancesCursor, Instances.BEGIN); 188 long endTimeMs = integer(eventInstancesCursor, Instances.END); 189 190 Instant startInstant = Instant.ofEpochMilli(startTimeMs); 191 Instant endInstant = Instant.ofEpochMilli(endTimeMs); 192 193 // If an event is all-day then the times are stored in UTC and must be adjusted. 194 if (allDay) { 195 startInstant = utcToDefaultTimeZone(startInstant); 196 endInstant = utcToDefaultTimeZone(endInstant); 197 } 198 199 String locationText = text(eventInstancesCursor, Instances.EVENT_LOCATION); 200 if (!mLocations.isValidLocation(locationText)) { 201 locationText = null; 202 } 203 204 List<Dialer.NumberAndAccess> numberAndAccesses = 205 eventDescriptions.extractNumberAndPins(descriptionText); 206 Dialer.NumberAndAccess numberAndAccess = Iterables.getFirst(numberAndAccesses, null); 207 long calendarColor = integer(eventInstancesCursor, Instances.CALENDAR_COLOR); 208 String calendarName = text(eventInstancesCursor, Instances.CALENDAR_DISPLAY_NAME); 209 int selfAttendeeStatus = 210 (int) integer(eventInstancesCursor, Instances.SELF_ATTENDEE_STATUS); 211 212 Event.Status status; 213 switch (selfAttendeeStatus) { 214 case CalendarContract.Attendees.ATTENDEE_STATUS_ACCEPTED: 215 status = Event.Status.ACCEPTED; 216 break; 217 case CalendarContract.Attendees.ATTENDEE_STATUS_DECLINED: 218 status = Event.Status.DECLINED; 219 break; 220 default: 221 status = Event.Status.NONE; 222 } 223 224 // Add an Event for each day of events that span multiple days. 225 List<Event> events = new ArrayList<>(); 226 Instant dayStartInstant = 227 startInstant.atZone(mClock.getZone()).truncatedTo(DAYS).toInstant(); 228 Instant dayEndInstant; 229 do { 230 dayEndInstant = dayStartInstant.plus(1, DAYS); 231 events.add( 232 new Event( 233 allDay, 234 startInstant, 235 dayStartInstant.isAfter(startInstant) ? dayStartInstant : startInstant, 236 endInstant, 237 dayEndInstant.isBefore(endInstant) ? dayEndInstant : endInstant, 238 titleText, 239 status, 240 locationText, 241 numberAndAccess, 242 new Event.CalendarDetails(calendarName, (int) calendarColor))); 243 dayStartInstant = dayEndInstant; 244 } while (dayStartInstant.isBefore(endInstant)); 245 return events; 246 } 247 utcToDefaultTimeZone(Instant instant)248 private Instant utcToDefaultTimeZone(Instant instant) { 249 return instant.atZone(ZoneId.of("UTC")).withZoneSameLocal(mClock.getZone()).toInstant(); 250 } 251 252 @Override onActive()253 protected void onActive() { 254 super.onActive(); 255 if (DEBUG) Log.d(TAG, "Live data active"); 256 mBackgroundHandler.post(this::updateAndScheduleNext); 257 } 258 259 @Override onInactive()260 protected void onInactive() { 261 super.onInactive(); 262 if (DEBUG) Log.d(TAG, "Live data inactive"); 263 mBackgroundHandler.post(this::cancelScheduledUpdate); 264 mBackgroundHandler.post(this::tearDownCursor); 265 } 266 267 /** Calls {@link #update()} every minute to keep the displayed time range correct. */ updateAndScheduleNext()268 private void updateAndScheduleNext() { 269 if (DEBUG) Log.d(TAG, "Update and schedule"); 270 if (hasActiveObservers()) { 271 update(); 272 ZonedDateTime now = ZonedDateTime.now(mClock); 273 ZonedDateTime truncatedNowTime = now.truncatedTo(MINUTES); 274 ZonedDateTime updateTime = truncatedNowTime.plus(1, MINUTES); 275 long delayMs = updateTime.toInstant().toEpochMilli() - now.toInstant().toEpochMilli(); 276 if (DEBUG) Log.d(TAG, "Scheduling in " + delayMs); 277 mBackgroundHandler.postDelayed(this::updateAndScheduleNext, this, delayMs); 278 } 279 } 280 cancelScheduledUpdate()281 private void cancelScheduledUpdate() { 282 mBackgroundHandler.removeCallbacksAndMessages(this); 283 } 284 tearDownCursor()285 private void tearDownCursor() { 286 if (mEventsCursor != null) { 287 if (DEBUG) Log.d(TAG, "Closing cursor and unregistering observer"); 288 mEventsCursor.unregisterContentObserver(mEventInstancesObserver); 289 mEventsCursor.close(); 290 mEventsCursor = null; 291 } else { 292 // Should not happen as the cursor should have been created first on the same handler. 293 Log.w(TAG, "Expected cursor"); 294 } 295 } 296 text(Cursor cursor, String columnName)297 private static String text(Cursor cursor, String columnName) { 298 return cursor.getString(cursor.getColumnIndex(columnName)); 299 } 300 301 /** An integer for the content provider is actually a Java long. */ integer(Cursor cursor, String columnName)302 private static long integer(Cursor cursor, String columnName) { 303 return cursor.getLong(cursor.getColumnIndex(columnName)); 304 } 305 } 306