/* * Copyright 2020 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.car.calendar.common; import static com.google.common.base.Preconditions.checkState; import static java.time.temporal.ChronoUnit.DAYS; import static java.time.temporal.ChronoUnit.MINUTES; import android.content.ContentResolver; import android.content.ContentUris; import android.database.ContentObserver; import android.database.Cursor; import android.net.Uri; import android.os.Handler; import android.provider.CalendarContract; import android.provider.CalendarContract.Instances; import android.util.Log; import androidx.lifecycle.LifecycleOwner; import androidx.lifecycle.LiveData; import androidx.lifecycle.Observer; import com.google.common.collect.ImmutableList; import com.google.common.collect.Iterables; import java.time.Clock; import java.time.Instant; import java.time.ZoneId; import java.time.ZonedDateTime; import java.time.temporal.ChronoUnit; import java.util.ArrayList; import java.util.Comparator; import java.util.List; import java.util.Objects; import javax.annotation.Nullable; /** * An observable source of calendar events coming from the Calendar * Provider. * *

While in the active state the content provider is observed for changes. * *

When the value given to the observer is null it signals that there are no calendars. */ public class EventsLiveData extends LiveData> { private static final String TAG = "CarCalendarEventsLiveData"; private static final boolean DEBUG = Log.isLoggable(TAG, Log.DEBUG); // The duration to delay before updating the value to reduce the frequency of changes. private static final int UPDATE_DELAY_MILLIS = 1000; // Sort events by start date and title. private static final Comparator EVENT_COMPARATOR = Comparator.comparing(Event::getDayStartInstant).thenComparing(Event::getTitle); private final Clock mClock; private final Handler mBackgroundHandler; private final ContentResolver mContentResolver; private final EventDescriptions mEventDescriptions; private final EventLocations mLocations; private final Runnable mUpdateIfChangedRunnable = this::updateIfChanged; /** The event instances cursor is a field to allow observers to be managed. */ @Nullable private Cursor mEventsCursor; @Nullable private ContentObserver mEventInstancesObserver; // This can be updated on the background thread but read from any thread. private volatile boolean mValueUpdated; public EventsLiveData( Clock clock, Handler backgroundHandler, ContentResolver contentResolver, EventDescriptions eventDescriptions, EventLocations locations) { mClock = clock; mBackgroundHandler = backgroundHandler; mContentResolver = contentResolver; mEventDescriptions = eventDescriptions; mLocations = locations; } /** Refreshes the event instances and sets the new value which notifies observers. */ private void updateIfChanged() { Log.d(TAG, "Update if changed"); ImmutableList latest = getEventsUntilTomorrow(); ImmutableList current = getValue(); // Always post the first value even if it is null. if (!mValueUpdated || !Objects.equals(latest, current)) { postValue(latest); mValueUpdated = true; } } /** Queries the content provider for event instances. */ @Nullable private ImmutableList getEventsUntilTomorrow() { // Check we are running on our background thread. checkState(mBackgroundHandler.getLooper().isCurrentThread()); if (mEventsCursor != null) { tearDownCursor(); } ZonedDateTime now = ZonedDateTime.now(mClock); // Find all events in the current day to include any all-day events. ZonedDateTime startDateTime = now.truncatedTo(DAYS); ZonedDateTime endDateTime = startDateTime.plusDays(2).truncatedTo(ChronoUnit.DAYS); // Always create the cursor so we can observe it for changes to events. mEventsCursor = createEventsCursor(startDateTime, endDateTime); // If there are no calendars we return null if (!hasCalendars()) { return null; } List events = new ArrayList<>(); while (mEventsCursor.moveToNext()) { List eventsForRow = createEventsForRow(mEventsCursor, mEventDescriptions); for (Event event : eventsForRow) { // Filter out any events that do not overlap the time window. if (event.getDayEndInstant().isBefore(now.toInstant()) || !event.getDayStartInstant().isBefore(endDateTime.toInstant())) { continue; } events.add(event); } } events.sort(EVENT_COMPARATOR); return ImmutableList.copyOf(events); } private boolean hasCalendars() { try (Cursor cursor = mContentResolver.query(CalendarContract.Calendars.CONTENT_URI, null, null, null)) { return cursor == null || cursor.getCount() > 0; } } /** Creates a new {@link Cursor} over event instances with an updated time range. */ private Cursor createEventsCursor(ZonedDateTime startDateTime, ZonedDateTime endDateTime) { Uri.Builder eventInstanceUriBuilder = CalendarContract.Instances.CONTENT_URI.buildUpon(); if (DEBUG) Log.d(TAG, "Reading from " + startDateTime + " to " + endDateTime); ContentUris.appendId(eventInstanceUriBuilder, startDateTime.toInstant().toEpochMilli()); ContentUris.appendId(eventInstanceUriBuilder, endDateTime.toInstant().toEpochMilli()); Uri eventInstanceUri = eventInstanceUriBuilder.build(); Cursor cursor = mContentResolver.query( eventInstanceUri, /* projection= */ null, /* selection= */ null, /* selectionArgs= */ null, Instances.BEGIN); // Set an observer on the Cursor, not the ContentResolver so it can be mocked for tests. mEventInstancesObserver = new ContentObserver(mBackgroundHandler) { @Override public boolean deliverSelfNotifications() { return true; } @Override public void onChange(boolean selfChange) { if (DEBUG) Log.d(TAG, "Events changed"); updateWithDelay(); } }; cursor.setNotificationUri(mContentResolver, eventInstanceUri); cursor.registerContentObserver(mEventInstancesObserver); return cursor; } private void updateWithDelay() { // Do not update the events until there have been no changes for a given duration. Log.d(TAG, "Events changed"); mBackgroundHandler.removeCallbacks(mUpdateIfChangedRunnable); mBackgroundHandler.postDelayed(mUpdateIfChangedRunnable, UPDATE_DELAY_MILLIS); } /** Can return multiple events for a single cursor row when an event spans multiple days. */ private List createEventsForRow( Cursor eventInstancesCursor, EventDescriptions eventDescriptions) { String titleText = text(eventInstancesCursor, Instances.TITLE); boolean allDay = integer(eventInstancesCursor, CalendarContract.Events.ALL_DAY) == 1; String descriptionText = text(eventInstancesCursor, Instances.DESCRIPTION); long startTimeMs = integer(eventInstancesCursor, Instances.BEGIN); long endTimeMs = integer(eventInstancesCursor, Instances.END); Instant startInstant = Instant.ofEpochMilli(startTimeMs); Instant endInstant = Instant.ofEpochMilli(endTimeMs); // If an event is all-day then the times are stored in UTC and must be adjusted. if (allDay) { startInstant = utcToDefaultTimeZone(startInstant); endInstant = utcToDefaultTimeZone(endInstant); } String locationText = text(eventInstancesCursor, Instances.EVENT_LOCATION); if (!mLocations.isValidLocation(locationText)) { locationText = null; } List numberAndAccesses = eventDescriptions.extractNumberAndPins(descriptionText); Dialer.NumberAndAccess numberAndAccess = Iterables.getFirst(numberAndAccesses, null); long calendarColor = integer(eventInstancesCursor, Instances.CALENDAR_COLOR); String calendarName = text(eventInstancesCursor, Instances.CALENDAR_DISPLAY_NAME); int selfAttendeeStatus = (int) integer(eventInstancesCursor, Instances.SELF_ATTENDEE_STATUS); Event.Status status; switch (selfAttendeeStatus) { case CalendarContract.Attendees.ATTENDEE_STATUS_ACCEPTED: status = Event.Status.ACCEPTED; break; case CalendarContract.Attendees.ATTENDEE_STATUS_DECLINED: status = Event.Status.DECLINED; break; default: status = Event.Status.NONE; } // Add an Event for each day of events that span multiple days. List events = new ArrayList<>(); Instant dayStartInstant = startInstant.atZone(mClock.getZone()).truncatedTo(DAYS).toInstant(); Instant dayEndInstant; do { dayEndInstant = dayStartInstant.plus(1, DAYS); events.add( new Event( allDay, startInstant, dayStartInstant.isAfter(startInstant) ? dayStartInstant : startInstant, endInstant, dayEndInstant.isBefore(endInstant) ? dayEndInstant : endInstant, titleText, status, locationText, numberAndAccess, new Event.CalendarDetails(calendarName, (int) calendarColor))); dayStartInstant = dayEndInstant; } while (dayStartInstant.isBefore(endInstant)); return events; } private Instant utcToDefaultTimeZone(Instant instant) { return instant.atZone(ZoneId.of("UTC")).withZoneSameLocal(mClock.getZone()).toInstant(); } @Override protected void onActive() { super.onActive(); if (DEBUG) Log.d(TAG, "Live data active"); mBackgroundHandler.post(this::updateAndScheduleNext); } @Override protected void onInactive() { super.onInactive(); if (DEBUG) Log.d(TAG, "Live data inactive"); mBackgroundHandler.post(this::cancelScheduledUpdate); mBackgroundHandler.post(this::tearDownCursor); mValueUpdated = false; } /** Calls {@link #updateIfChanged()} every minute to keep the displayed time range correct. */ private void updateAndScheduleNext() { if (DEBUG) Log.d(TAG, "Update and schedule"); if (hasActiveObservers()) { updateIfChanged(); ZonedDateTime now = ZonedDateTime.now(mClock); ZonedDateTime truncatedNowTime = now.truncatedTo(MINUTES); ZonedDateTime updateTime = truncatedNowTime.plus(1, MINUTES); long delayMs = updateTime.toInstant().toEpochMilli() - now.toInstant().toEpochMilli(); if (DEBUG) Log.d(TAG, "Scheduling in " + delayMs); mBackgroundHandler.postDelayed(this::updateAndScheduleNext, this, delayMs); } } private void cancelScheduledUpdate() { mBackgroundHandler.removeCallbacksAndMessages(this); } private void tearDownCursor() { if (mEventsCursor != null) { if (DEBUG) Log.d(TAG, "Closing cursor and unregistering observer"); mEventsCursor.unregisterContentObserver(mEventInstancesObserver); mEventsCursor.close(); mEventsCursor = null; } else { // Should not happen as the cursor should have been created first on the same handler. Log.w(TAG, "Expected cursor"); } } private static String text(Cursor cursor, String columnName) { return cursor.getString(cursor.getColumnIndex(columnName)); } /** An integer for the content provider is actually a Java long. */ private static long integer(Cursor cursor, String columnName) { return cursor.getLong(cursor.getColumnIndex(columnName)); } }