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