From a66655fd373cc9ef9ff06516a19141c60d2c2422 Mon Sep 17 00:00:00 2001 From: John Patterson Date: Wed, 1 Jul 2020 12:45:04 +0200 Subject: Add CarCalendarApp This was moved from vendor/auto/embedded/apps/Calendar Header licence was updated in all files Added OWNERS file Bug: 140862425 Bug: 160135021 Merged-In: I64cca6684ba132f47308911bdc73f35fe2176502 Test: clean build from root still works Change-Id: I64cca6684ba132f47308911bdc73f35fe2176502 --- .../car/calendar/common/CalendarFormatterTest.java | 98 ++++ .../car/calendar/common/EventDescriptionsTest.java | 131 +++++ .../car/calendar/common/EventLocationsTest.java | 60 ++ .../car/calendar/common/EventsLiveDataTest.java | 609 +++++++++++++++++++++ 4 files changed, 898 insertions(+) create mode 100644 tests/unit/src/com/android/car/calendar/common/CalendarFormatterTest.java create mode 100644 tests/unit/src/com/android/car/calendar/common/EventDescriptionsTest.java create mode 100644 tests/unit/src/com/android/car/calendar/common/EventLocationsTest.java create mode 100644 tests/unit/src/com/android/car/calendar/common/EventsLiveDataTest.java (limited to 'tests/unit/src/com') diff --git a/tests/unit/src/com/android/car/calendar/common/CalendarFormatterTest.java b/tests/unit/src/com/android/car/calendar/common/CalendarFormatterTest.java new file mode 100644 index 0000000..132504e --- /dev/null +++ b/tests/unit/src/com/android/car/calendar/common/CalendarFormatterTest.java @@ -0,0 +1,98 @@ +/* + * 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.truth.Truth.assertThat; + +import android.content.Context; + +import androidx.test.platform.app.InstrumentationRegistry; + +import org.junit.Before; +import org.junit.Test; + +import java.time.Clock; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.time.ZonedDateTime; +import java.util.Locale; + +public class CalendarFormatterTest { + + private static final ZoneId BERLIN_ZONE_ID = ZoneId.of("Europe/Berlin"); + private static final ZonedDateTime CURRENT_DATE_TIME = + LocalDateTime.of(2019, 12, 10, 10, 10, 10, 500500).atZone(BERLIN_ZONE_ID); + private static final Locale LOCALE = Locale.ENGLISH; + private CalendarFormatter mFormatter; + + @Before + public void setUp() { + Context context = InstrumentationRegistry.getInstrumentation().getTargetContext(); + Clock clock = Clock.fixed(CURRENT_DATE_TIME.toInstant(), BERLIN_ZONE_ID); + mFormatter = new CalendarFormatter(context, LOCALE, clock); + } + + @Test + public void getDateText_today() { + String dateText = mFormatter.getDateText(CURRENT_DATE_TIME.toLocalDate()); + + assertThat(dateText).startsWith("Today"); + assertThat(dateText).endsWith("Tue, Dec 10"); + } + + @Test + public void getDateText_tomorrow() { + String dateText = mFormatter.getDateText(CURRENT_DATE_TIME.plusDays(1).toLocalDate()); + + assertThat(dateText).startsWith("Tomorrow"); + assertThat(dateText).endsWith("Wed, Dec 11"); + } + + @Test + public void getDateText_nextWeek_onlyShowsDate() { + String dateText = mFormatter.getDateText(CURRENT_DATE_TIME.plusDays(7).toLocalDate()); + + assertThat(dateText).isEqualTo("Tue, Dec 17"); + } + + @Test + public void getTimeRangeText_sameAmPm() { + String dateText = + mFormatter.getTimeRangeText( + CURRENT_DATE_TIME.toInstant(), CURRENT_DATE_TIME.plusHours(1).toInstant()); + + assertThat(dateText).isEqualTo("10:10 – 11:10 AM"); + } + + @Test + public void getTimeRangeText_differentAmPm() { + String dateText = + mFormatter.getTimeRangeText( + CURRENT_DATE_TIME.toInstant(), CURRENT_DATE_TIME.plusHours(3).toInstant()); + + assertThat(dateText).isEqualTo("10:10 AM – 1:10 PM"); + } + + @Test + public void getTimeRangeText_differentDays() { + String dateText = + mFormatter.getTimeRangeText( + CURRENT_DATE_TIME.toInstant(), CURRENT_DATE_TIME.plusDays(1).toInstant()); + + assertThat(dateText).isEqualTo("Dec 10, 10:10 AM – Dec 11, 10:10 AM"); + } +} diff --git a/tests/unit/src/com/android/car/calendar/common/EventDescriptionsTest.java b/tests/unit/src/com/android/car/calendar/common/EventDescriptionsTest.java new file mode 100644 index 0000000..358e9cf --- /dev/null +++ b/tests/unit/src/com/android/car/calendar/common/EventDescriptionsTest.java @@ -0,0 +1,131 @@ +/* + * 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.truth.Truth.assertThat; + +import android.net.Uri; + +import com.google.common.collect.Iterables; + +import org.junit.Before; +import org.junit.Test; + +import java.io.UnsupportedEncodingException; +import java.util.List; +import java.util.Locale; + +public class EventDescriptionsTest { + + private static final String BASE_NUMBER = "30 303986300"; + private static final String LOCAL_NUMBER = "0" + BASE_NUMBER; + private static final String INTERNATIONAL_NUMBER = "+49 " + BASE_NUMBER; + private static final String ACCESS = ",,12;3*45#"; + private EventDescriptions mEventDescriptions; + + @Before + public void setUp() { + mEventDescriptions = new EventDescriptions(Locale.GERMANY); + } + + @Test + public void extractNumberAndPin_localNumber_resultIsLocal() { + List numberAndAccesses = + mEventDescriptions.extractNumberAndPins(LOCAL_NUMBER); + assertThat(numberAndAccesses).isNotEmpty(); + Dialer.NumberAndAccess numberAndAccess = Iterables.getFirst(numberAndAccesses, null); + assertThat(numberAndAccess.getNumber()).isEqualTo(INTERNATIONAL_NUMBER); + } + + @Test + public void extractNumberAndPin_internationalNumber_resultIsLocal() { + List numberAndAccesses = + mEventDescriptions.extractNumberAndPins(INTERNATIONAL_NUMBER); + assertThat(numberAndAccesses).isNotEmpty(); + Dialer.NumberAndAccess numberAndAccess = Iterables.getFirst(numberAndAccesses, null); + assertThat(numberAndAccess.getNumber()).isEqualTo(INTERNATIONAL_NUMBER); + } + + @Test + public void extractNumberAndPin_internationalNumberWithDifferentLocale_resultIsInternational() { + mEventDescriptions = new EventDescriptions(Locale.FRANCE); + List numberAndAccesses = + mEventDescriptions.extractNumberAndPins(INTERNATIONAL_NUMBER); + assertThat(numberAndAccesses).isNotEmpty(); + Dialer.NumberAndAccess numberAndAccess = Iterables.getFirst(numberAndAccesses, null); + assertThat(numberAndAccess.getNumber()).isEqualTo(INTERNATIONAL_NUMBER); + } + + @Test + public void extractNumberAndPin_internationalNumberAndPin() { + String input = INTERNATIONAL_NUMBER + " PIN: " + ACCESS; + List numberAndAccesses = + mEventDescriptions.extractNumberAndPins(input); + assertThat(numberAndAccesses).isNotEmpty(); + Dialer.NumberAndAccess numberAndAccess = Iterables.getFirst(numberAndAccesses, null); + assertThat(numberAndAccess.getNumber()).isEqualTo(INTERNATIONAL_NUMBER); + assertThat(numberAndAccess.getAccess()).isEqualTo(ACCESS); + } + + @Test + public void extractNumberAndPin_internationalNumberAndCode() { + String input = INTERNATIONAL_NUMBER + " with access code " + ACCESS; + List numberAndAccesses = + mEventDescriptions.extractNumberAndPins(input); + assertThat(numberAndAccesses).isNotEmpty(); + Dialer.NumberAndAccess numberAndAccess = Iterables.getFirst(numberAndAccesses, null); + assertThat(numberAndAccess.getNumber()).isEqualTo(INTERNATIONAL_NUMBER); + assertThat(numberAndAccess.getAccess()).isEqualTo(ACCESS); + } + + @Test + public void extractNumberAndPin_multipleNumbers() { + String input = + INTERNATIONAL_NUMBER + + " PIN: " + + ACCESS + + "\n an invalid one is " + + BASE_NUMBER + + " but a local one is " + + LOCAL_NUMBER; + List numberAndAccesses = + mEventDescriptions.extractNumberAndPins(input); + + // The local number is valid but repeated so only included once. + assertThat(numberAndAccesses).hasSize(1); + } + + @Test + public void extractNumberAndPin_encodedTelFormat() throws UnsupportedEncodingException { + String encoded = Uri.encode(INTERNATIONAL_NUMBER + ACCESS); + String input = "blah blah blah blah"; + List numberAndAccesses = + mEventDescriptions.extractNumberAndPins(input); + assertThat(numberAndAccesses).hasSize(1); + Dialer.NumberAndAccess numberAndAccess = Iterables.getFirst(numberAndAccesses, null); + assertThat(numberAndAccess.getNumber()).isEqualTo(INTERNATIONAL_NUMBER); + assertThat(numberAndAccess.getAccess()).isEqualTo(ACCESS); + } + + @Test + public void extractNumberAndPin_smallNumber_returnsNull() throws UnsupportedEncodingException { + String input = "blah blah 345 - blah blah"; + List numberAndAccesses = + mEventDescriptions.extractNumberAndPins(input); + assertThat(numberAndAccesses).isEmpty(); + } +} diff --git a/tests/unit/src/com/android/car/calendar/common/EventLocationsTest.java b/tests/unit/src/com/android/car/calendar/common/EventLocationsTest.java new file mode 100644 index 0000000..23f833a --- /dev/null +++ b/tests/unit/src/com/android/car/calendar/common/EventLocationsTest.java @@ -0,0 +1,60 @@ +/* + * 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.truth.Truth.assertThat; + +import org.junit.Before; +import org.junit.Test; + +public class EventLocationsTest { + + private static final String BASE_NUMBER = "30 303986300"; + + private EventLocations mEventLocations; + + @Before + public void setUp() { + mEventLocations = new EventLocations(); + } + + @Test + public void isValidLocation_meetingRooms_isFalse() { + assertThat(mEventLocations.isValidLocation("MUC-ARP-6Z3-Radln (5) [GVC]")).isFalse(); + assertThat(mEventLocations.isValidLocation("SFO-SPE-3-Anchor Brewing Co. (1) [GVC]")) + .isFalse(); + assertThat(mEventLocations.isValidLocation("SFO-SPE-3-Speakeasy Ales & Lagers (10) [GVC]")) + .isFalse(); + assertThat( + mEventLocations.isValidLocation( + "MTV-900-1-Good Charlotte (13) [GVC, No External Guests]")) + .isFalse(); + assertThat( + mEventLocations.isValidLocation( + "MTV-900-2-Panic! at the Disco (5) [GVC, Jamboard]")) + .isFalse(); + assertThat(mEventLocations.isValidLocation("US-MTV-900-1-1F2 (collaboration area)")) + .isFalse(); + } + + @Test + public void isValidLocation_notMeetingRooms_isTrue() { + assertThat(mEventLocations.isValidLocation("My place")).isTrue(); + assertThat(mEventLocations.isValidLocation("At JDP-1974-09")).isTrue(); + assertThat(mEventLocations.isValidLocation("178.3454, 234.345")).isTrue(); + } +} diff --git a/tests/unit/src/com/android/car/calendar/common/EventsLiveDataTest.java b/tests/unit/src/com/android/car/calendar/common/EventsLiveDataTest.java new file mode 100644 index 0000000..ff00e8d --- /dev/null +++ b/tests/unit/src/com/android/car/calendar/common/EventsLiveDataTest.java @@ -0,0 +1,609 @@ +/* + * 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.truth.Truth.assertThat; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import static java.time.temporal.ChronoUnit.HOURS; + +import android.Manifest; +import android.content.Context; +import android.database.ContentObserver; +import android.database.Cursor; +import android.database.MatrixCursor; +import android.net.Uri; +import android.os.Bundle; +import android.os.CancellationSignal; +import android.os.Handler; +import android.os.HandlerThread; +import android.os.Message; +import android.os.Process; +import android.os.SystemClock; +import android.provider.CalendarContract; +import android.test.mock.MockContentProvider; +import android.test.mock.MockContentResolver; + +import androidx.lifecycle.Observer; +import androidx.test.annotation.UiThreadTest; +import androidx.test.ext.junit.runners.AndroidJUnit4; +import androidx.test.platform.app.InstrumentationRegistry; +import androidx.test.rule.GrantPermissionRule; + +import com.google.common.collect.ImmutableList; + +import org.junit.After; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.runner.RunWith; + +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.time.ZonedDateTime; +import java.time.temporal.ChronoField; +import java.time.temporal.ChronoUnit; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; + +@RunWith(AndroidJUnit4.class) +public class EventsLiveDataTest { + private static final ZoneId BERLIN_ZONE_ID = ZoneId.of("Europe/Berlin"); + private static final ZonedDateTime CURRENT_DATE_TIME = + LocalDateTime.of(2019, 12, 10, 10, 10, 10, 500500).atZone(BERLIN_ZONE_ID); + private static final Dialer.NumberAndAccess EVENT_NUMBER_PIN = + new Dialer.NumberAndAccess("the number", "the pin"); + private static final String EVENT_TITLE = "the title"; + private static final boolean EVENT_ALL_DAY = false; + private static final String EVENT_LOCATION = "the location"; + private static final String EVENT_DESCRIPTION = "the description"; + private static final String CALENDAR_NAME = "the calendar name"; + private static final int CALENDAR_COLOR = 0xCAFEBABE; + private static final int EVENT_ATTENDEE_STATUS = + CalendarContract.Attendees.ATTENDEE_STATUS_ACCEPTED; + + @Rule + public final GrantPermissionRule permissionRule = + GrantPermissionRule.grant(Manifest.permission.READ_CALENDAR); + + private EventsLiveData mEventsLiveData; + private TestContentProvider mTestContentProvider; + private TestHandler mTestHandler; + private TestClock mTestClock; + + @Before + public void setUp() { + mTestClock = new TestClock(BERLIN_ZONE_ID); + mTestClock.setTime(CURRENT_DATE_TIME); + Context context = InstrumentationRegistry.getInstrumentation().getTargetContext(); + + // Create a fake result for the calendar content provider. + MockContentResolver mockContentResolver = new MockContentResolver(context); + + mTestContentProvider = new TestContentProvider(context); + mockContentResolver.addProvider(CalendarContract.AUTHORITY, mTestContentProvider); + + EventDescriptions mockEventDescriptions = mock(EventDescriptions.class); + when(mockEventDescriptions.extractNumberAndPins(any())) + .thenReturn(ImmutableList.of(EVENT_NUMBER_PIN)); + + EventLocations mockEventLocations = mock(EventLocations.class); + when(mockEventLocations.isValidLocation(anyString())).thenReturn(true); + mTestHandler = TestHandler.create(); + mEventsLiveData = + new EventsLiveData( + mTestClock, + mTestHandler, + mockContentResolver, + mockEventDescriptions, + mockEventLocations); + } + + @After + public void tearDown() { + if (mTestHandler != null) { + mTestHandler.stop(); + } + } + + @Test + public void noObserver_noQueryMade() { + // No query should be made because there are no observers. + assertThat(mTestContentProvider.mTestEventCursor).isNull(); + } + + @Test + @UiThreadTest + public void addObserver_queryMade() throws InterruptedException { + // Expect onChanged to be called for when we start to observe and when the data is read. + CountDownLatch latch = new CountDownLatch(2); + mEventsLiveData.observeForever((value) -> latch.countDown()); + + // Wait for the data to be read on the background thread. + latch.await(5, TimeUnit.SECONDS); + + assertThat(mTestContentProvider.mTestEventCursor).isNotNull(); + } + + @Test + @UiThreadTest + public void addObserver_contentObserved() throws InterruptedException { + // Expect onChanged to be called for when we start to observe and when the data is read. + CountDownLatch latch = new CountDownLatch(2); + mEventsLiveData.observeForever((value) -> latch.countDown()); + + // Wait for the data to be read on the background thread. + latch.await(5, TimeUnit.SECONDS); + + assertThat(mTestContentProvider.mTestEventCursor.mLastContentObserver).isNotNull(); + } + + @Test + @UiThreadTest + public void removeObserver_contentNotObserved() throws InterruptedException { + // Expect onChanged when we observe, when the data is read, and when we stop observing. + final CountDownLatch latch = new CountDownLatch(2); + Observer> observer = (value) -> latch.countDown(); + mEventsLiveData.observeForever(observer); + + // Wait for the data to be read on the background thread. + latch.await(5, TimeUnit.SECONDS); + + final CountDownLatch latch2 = new CountDownLatch(1); + mEventsLiveData.removeObserver(observer); + + // Wait for the observer to be unregistered on the background thread. + latch2.await(5, TimeUnit.SECONDS); + + assertThat(mTestContentProvider.mTestEventCursor.mLastContentObserver).isNull(); + } + + @Test + public void addObserver_oneEventResult() throws InterruptedException { + + mTestContentProvider.addRow(buildTestRowWithDuration(CURRENT_DATE_TIME, 1)); + + // Expect onChanged to be called for when we start to observe and when the data is read. + CountDownLatch latch = new CountDownLatch(2); + + // Must add observer on main thread. + runOnMain(() -> mEventsLiveData.observeForever((value) -> latch.countDown())); + + // Wait for the data to be read on the background thread. + latch.await(5, TimeUnit.SECONDS); + + ImmutableList events = mEventsLiveData.getValue(); + assertThat(events).isNotNull(); + assertThat(events).hasSize(1); + Event event = events.get(0); + + long eventStartMillis = addHoursAndTruncate(CURRENT_DATE_TIME, 0); + long eventEndMillis = addHoursAndTruncate(CURRENT_DATE_TIME, 1); + + assertThat(event.getTitle()).isEqualTo(EVENT_TITLE); + assertThat(event.getCalendarDetails().getColor()).isEqualTo(CALENDAR_COLOR); + assertThat(event.getLocation()).isEqualTo(EVENT_LOCATION); + assertThat(event.getStartInstant().toEpochMilli()).isEqualTo(eventStartMillis); + assertThat(event.getEndInstant().toEpochMilli()).isEqualTo(eventEndMillis); + assertThat(event.getStatus()).isEqualTo(Event.Status.ACCEPTED); + assertThat(event.getNumberAndAccess()).isEqualTo(EVENT_NUMBER_PIN); + } + + @Test + public void changeCursorData_onChangedCalled() throws InterruptedException { + // Expect onChanged to be called for when we start to observe and when the data is read. + CountDownLatch initializeCountdownLatch = new CountDownLatch(2); + + // Expect the same init callbacks as above but with an extra when the data is updated. + CountDownLatch changeCountdownLatch = new CountDownLatch(3); + + // Must add observer on main thread. + runOnMain( + () -> + mEventsLiveData.observeForever( + // Count down both latches when data is changed. + (value) -> { + initializeCountdownLatch.countDown(); + changeCountdownLatch.countDown(); + })); + + // Wait for the data to be read on the background thread. + initializeCountdownLatch.await(5, TimeUnit.SECONDS); + + // Signal that the content has changed. + mTestContentProvider.mTestEventCursor.signalDataChanged(); + + // Wait for the changed data to be read on the background thread. + changeCountdownLatch.await(5, TimeUnit.SECONDS); + } + + private void runOnMain(Runnable runnable) { + InstrumentationRegistry.getInstrumentation().runOnMainSync(runnable); + } + + @Test + public void addObserver_updateScheduled() throws InterruptedException { + mTestHandler.setExpectedMessageCount(2); + + // Must add observer on main thread. + runOnMain( + () -> + mEventsLiveData.observeForever( + (value) -> { + /* Do nothing */ + })); + + mTestHandler.awaitExpectedMessages(5); + + // Show that a message was scheduled for the future. + assertThat(mTestHandler.mLastUptimeMillis).isAtLeast(SystemClock.uptimeMillis()); + } + + @Test + public void noCalendars_valueNull() throws InterruptedException { + mTestContentProvider.mAddFakeCalendar = false; + + // Expect onChanged to be called for when we start to observe and when the data is read. + CountDownLatch latch = new CountDownLatch(2); + runOnMain(() -> mEventsLiveData.observeForever((value) -> latch.countDown())); + + // Wait for the data to be read on the background thread. + latch.await(5, TimeUnit.SECONDS); + + assertThat(mEventsLiveData.getValue()).isNull(); + } + + @Test + @UiThreadTest + public void noCalendars_contentObserved() throws InterruptedException { + mTestContentProvider.mAddFakeCalendar = false; + + // Expect onChanged to be called for when we start to observe and when the data is read. + CountDownLatch latch = new CountDownLatch(2); + mEventsLiveData.observeForever((value) -> latch.countDown()); + + // Wait for the data to be read on the background thread. + latch.await(5, TimeUnit.SECONDS); + + assertThat(mTestContentProvider.mTestEventCursor.mLastContentObserver).isNotNull(); + } + + @Test + public void multiDayEvent_createsMultipleEvents() throws InterruptedException { + // Replace the default event with one that lasts 24 hours. + mTestContentProvider.addRow(buildTestRowWithDuration(CURRENT_DATE_TIME, 24)); + + // Expect onChanged to be called for when we start to observe and when the data is read. + CountDownLatch latch = new CountDownLatch(2); + + runOnMain(() -> mEventsLiveData.observeForever((value) -> latch.countDown())); + + // Wait for the data to be read on the background thread. + latch.await(5, TimeUnit.SECONDS); + + // Expect an event for the 2 parts of the split event instance. + assertThat(mEventsLiveData.getValue()).hasSize(2); + } + + @Test + public void multiDayEvent_keepsOriginalTimes() throws InterruptedException { + // Replace the default event with one that lasts 24 hours. + int hours = 48; + mTestContentProvider.addRow(buildTestRowWithDuration(CURRENT_DATE_TIME, hours)); + + // Expect onChanged to be called for when we start to observe and when the data is read. + CountDownLatch latch = new CountDownLatch(2); + + runOnMain(() -> mEventsLiveData.observeForever((value) -> latch.countDown())); + + // Wait for the data to be read on the background thread. + latch.await(5, TimeUnit.SECONDS); + + Event middlePartEvent = mEventsLiveData.getValue().get(1); + + // The start and end times should remain the original times. + ZonedDateTime expectedStartTime = CURRENT_DATE_TIME.truncatedTo(HOURS); + assertThat(middlePartEvent.getStartInstant()).isEqualTo(expectedStartTime.toInstant()); + ZonedDateTime expectedEndTime = expectedStartTime.plus(hours, HOURS); + assertThat(middlePartEvent.getEndInstant()).isEqualTo(expectedEndTime.toInstant()); + } + + @Test + public void multipleEvents_resultsSortedStart() throws InterruptedException { + // Replace the default event with two that are out of time order. + ZonedDateTime twoHoursAfterCurrentTime = CURRENT_DATE_TIME.plus(Duration.ofHours(2)); + mTestContentProvider.addRow(buildTestRowWithDuration(twoHoursAfterCurrentTime, 1)); + mTestContentProvider.addRow(buildTestRowWithDuration(CURRENT_DATE_TIME, 1)); + + // Expect onChanged to be called for when we start to observe and when the data is read. + CountDownLatch latch = new CountDownLatch(2); + + runOnMain(() -> mEventsLiveData.observeForever((value) -> latch.countDown())); + + // Wait for the data to be read on the background thread. + latch.await(5, TimeUnit.SECONDS); + + ImmutableList events = mEventsLiveData.getValue(); + + assertThat(events.get(0).getStartInstant().toEpochMilli()) + .isEqualTo(addHoursAndTruncate(CURRENT_DATE_TIME, 0)); + assertThat(events.get(1).getStartInstant().toEpochMilli()) + .isEqualTo(addHoursAndTruncate(CURRENT_DATE_TIME, 2)); + } + + @Test + public void multipleEvents_resultsSortedTitle() throws InterruptedException { + // Replace the default event with two that are out of time order. + mTestContentProvider.addRow(buildTestRowWithTitle(CURRENT_DATE_TIME, "Title B")); + mTestContentProvider.addRow(buildTestRowWithTitle(CURRENT_DATE_TIME, "Title A")); + mTestContentProvider.addRow(buildTestRowWithTitle(CURRENT_DATE_TIME, "Title C")); + + // Expect onChanged to be called for when we start to observe and when the data is read. + CountDownLatch latch = new CountDownLatch(2); + + runOnMain(() -> mEventsLiveData.observeForever((value) -> latch.countDown())); + + // Wait for the data to be read on the background thread. + latch.await(5, TimeUnit.SECONDS); + + ImmutableList events = mEventsLiveData.getValue(); + + assertThat(events.get(0).getTitle()).isEqualTo("Title A"); + assertThat(events.get(1).getTitle()).isEqualTo("Title B"); + assertThat(events.get(2).getTitle()).isEqualTo("Title C"); + } + + @Test + public void allDayEvent_timesSetToLocal() throws InterruptedException { + // All-day events always start at UTC midnight. + ZonedDateTime utcMidnightStart = + CURRENT_DATE_TIME.withZoneSameLocal(ZoneId.of("UTC")).truncatedTo(ChronoUnit.DAYS); + mTestContentProvider.addRow(buildTestRowAllDay(utcMidnightStart)); + + // Expect onChanged to be called for when we start to observe and when the data is read. + CountDownLatch latch = new CountDownLatch(2); + + runOnMain(() -> mEventsLiveData.observeForever((value) -> latch.countDown())); + + // Wait for the data to be read on the background thread. + latch.await(5, TimeUnit.SECONDS); + + ImmutableList events = mEventsLiveData.getValue(); + + Instant localMidnightStart = CURRENT_DATE_TIME.truncatedTo(ChronoUnit.DAYS).toInstant(); + assertThat(events.get(0).getStartInstant()).isEqualTo(localMidnightStart); + } + + @Test + public void allDayEvent_queryCoversLocalDayStart() throws InterruptedException { + // All-day events always start at UTC midnight. + ZonedDateTime utcMidnightStart = + CURRENT_DATE_TIME.withZoneSameLocal(ZoneId.of("UTC")).truncatedTo(ChronoUnit.DAYS); + mTestContentProvider.addRow(buildTestRowAllDay(utcMidnightStart)); + + // Set the time to 23:XX in the BERLIN_ZONE_ID which will be after the event end time. + mTestClock.setTime(CURRENT_DATE_TIME.with(ChronoField.HOUR_OF_DAY, 23)); + + // Expect onChanged to be called for when we start to observe and when the data is read. + CountDownLatch latch = new CountDownLatch(2); + + runOnMain(() -> mEventsLiveData.observeForever((value) -> latch.countDown())); + + // Wait for the data to be read on the background thread. + latch.await(5, TimeUnit.SECONDS); + + // Show that the event is included even though its end time is before the current time. + assertThat(mEventsLiveData.getValue()).isNotEmpty(); + } + + private static class TestContentProvider extends MockContentProvider { + TestEventCursor mTestEventCursor; + boolean mAddFakeCalendar = true; + List mEventRows = new ArrayList<>(); + + TestContentProvider(Context context) { + super(context); + } + + private void addRow(Object[] row) { + mEventRows.add(row); + } + + @Override + public Cursor query( + Uri uri, + String[] projection, + Bundle queryArgs, + CancellationSignal cancellationSignal) { + if (uri.toString().startsWith(CalendarContract.Instances.CONTENT_URI.toString())) { + mTestEventCursor = new TestEventCursor(uri); + for (Object[] row : mEventRows) { + mTestEventCursor.addRow(row); + } + return mTestEventCursor; + } else if (uri.equals(CalendarContract.Calendars.CONTENT_URI)) { + MatrixCursor calendarsCursor = new MatrixCursor(new String[] {" Test name"}); + if (mAddFakeCalendar) { + calendarsCursor.addRow(new String[] {"Test value"}); + } + return calendarsCursor; + } + throw new IllegalStateException("Unexpected query uri " + uri); + } + + static class TestEventCursor extends MatrixCursor { + final Uri mUri; + ContentObserver mLastContentObserver; + + TestEventCursor(Uri uri) { + super( + new String[] { + CalendarContract.Instances.TITLE, + CalendarContract.Instances.ALL_DAY, + CalendarContract.Instances.BEGIN, + CalendarContract.Instances.END, + CalendarContract.Instances.DESCRIPTION, + CalendarContract.Instances.EVENT_LOCATION, + CalendarContract.Instances.SELF_ATTENDEE_STATUS, + CalendarContract.Instances.CALENDAR_COLOR, + CalendarContract.Instances.CALENDAR_DISPLAY_NAME, + }); + mUri = uri; + } + + @Override + public void registerContentObserver(ContentObserver observer) { + super.registerContentObserver(observer); + mLastContentObserver = observer; + } + + @Override + public void unregisterContentObserver(ContentObserver observer) { + super.unregisterContentObserver(observer); + mLastContentObserver = null; + } + + void signalDataChanged() { + super.onChange(true); + } + } + } + + private static class TestHandler extends Handler { + final HandlerThread mThread; + long mLastUptimeMillis; + CountDownLatch mCountDownLatch; + + static TestHandler create() { + HandlerThread thread = + new HandlerThread( + EventsLiveDataTest.class.getSimpleName(), + Process.THREAD_PRIORITY_FOREGROUND); + thread.start(); + return new TestHandler(thread); + } + + TestHandler(HandlerThread thread) { + super(thread.getLooper()); + mThread = thread; + } + + void stop() { + mThread.quit(); + } + + void setExpectedMessageCount(int expectedMessageCount) { + mCountDownLatch = new CountDownLatch(expectedMessageCount); + } + + void awaitExpectedMessages(int seconds) throws InterruptedException { + mCountDownLatch.await(seconds, TimeUnit.SECONDS); + } + + @Override + public boolean sendMessageAtTime(Message msg, long uptimeMillis) { + mLastUptimeMillis = uptimeMillis; + if (mCountDownLatch != null) { + mCountDownLatch.countDown(); + } + return super.sendMessageAtTime(msg, uptimeMillis); + } + } + + // Similar to {@link android.os.SimpleClock} but without @hide and with mutable millis. + static class TestClock extends Clock { + private final ZoneId mZone; + private long mTimeMs; + + TestClock(ZoneId zone) { + mZone = zone; + } + + void setTime(ZonedDateTime time) { + mTimeMs = time.toInstant().toEpochMilli(); + } + + @Override + public ZoneId getZone() { + return mZone; + } + + @Override + public Clock withZone(ZoneId zone) { + return new TestClock(zone) { + @Override + public long millis() { + return TestClock.this.millis(); + } + }; + } + + @Override + public long millis() { + return mTimeMs; + } + + @Override + public Instant instant() { + return Instant.ofEpochMilli(millis()); + } + } + + static long addHoursAndTruncate(ZonedDateTime dateTime, int hours) { + return dateTime.truncatedTo(HOURS) + .plus(Duration.ofHours(hours)) + .toInstant() + .toEpochMilli(); + } + + static Object[] buildTestRowWithDuration(ZonedDateTime startDateTime, int eventDurationHours) { + return buildTestRowWithDuration( + startDateTime, eventDurationHours, EVENT_TITLE, EVENT_ALL_DAY); + } + + static Object[] buildTestRowAllDay(ZonedDateTime startDateTime) { + return buildTestRowWithDuration(startDateTime, 24, EVENT_TITLE, true); + } + + static Object[] buildTestRowWithTitle(ZonedDateTime startDateTime, String title) { + return buildTestRowWithDuration(startDateTime, 1, title, EVENT_ALL_DAY); + } + + static Object[] buildTestRowWithDuration( + ZonedDateTime currentDateTime, int eventDurationHours, String title, boolean allDay) { + return new Object[] { + title, + allDay ? 1 : 0, + addHoursAndTruncate(currentDateTime, 0), + addHoursAndTruncate(currentDateTime, eventDurationHours), + EVENT_DESCRIPTION, + EVENT_LOCATION, + EVENT_ATTENDEE_STATUS, + CALENDAR_COLOR, + CALENDAR_NAME + }; + } +} -- cgit v1.2.3