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;
18 
19 import static androidx.test.espresso.Espresso.onView;
20 import static androidx.test.espresso.action.ViewActions.click;
21 import static androidx.test.espresso.assertion.ViewAssertions.doesNotExist;
22 import static androidx.test.espresso.assertion.ViewAssertions.matches;
23 import static androidx.test.espresso.matcher.ViewMatchers.isDisplayed;
24 import static androidx.test.espresso.matcher.ViewMatchers.withId;
25 import static androidx.test.espresso.matcher.ViewMatchers.withText;
26 
27 import static org.hamcrest.CoreMatchers.not;
28 
29 import android.Manifest;
30 import android.app.Activity;
31 import android.content.Context;
32 import android.database.Cursor;
33 import android.database.MatrixCursor;
34 import android.net.Uri;
35 import android.os.Bundle;
36 import android.os.CancellationSignal;
37 import android.provider.CalendarContract;
38 import android.test.mock.MockContentProvider;
39 import android.test.mock.MockContentResolver;
40 
41 import androidx.lifecycle.Observer;
42 import androidx.lifecycle.ViewModelProvider;
43 import androidx.test.core.app.ActivityScenario;
44 import androidx.test.ext.junit.runners.AndroidJUnit4;
45 import androidx.test.filters.LargeTest;
46 import androidx.test.platform.app.InstrumentationRegistry;
47 import androidx.test.rule.GrantPermissionRule;
48 import androidx.test.runner.lifecycle.ActivityLifecycleCallback;
49 import androidx.test.runner.lifecycle.ActivityLifecycleMonitorRegistry;
50 import androidx.test.runner.lifecycle.Stage;
51 
52 import com.android.car.calendar.common.Event;
53 import com.android.car.calendar.common.EventsLiveData;
54 
55 import com.google.common.collect.ImmutableList;
56 
57 import org.junit.After;
58 import org.junit.Before;
59 import org.junit.Rule;
60 import org.junit.Test;
61 import org.junit.runner.RunWith;
62 
63 import java.time.Clock;
64 import java.time.LocalDateTime;
65 import java.time.ZoneId;
66 import java.time.ZonedDateTime;
67 import java.time.temporal.ChronoUnit;
68 import java.util.ArrayList;
69 import java.util.List;
70 import java.util.Locale;
71 import java.util.concurrent.CountDownLatch;
72 import java.util.concurrent.TimeUnit;
73 
74 @LargeTest
75 @RunWith(AndroidJUnit4.class)
76 public class CarCalendarUiTest {
77     private static final ZoneId BERLIN_ZONE_ID = ZoneId.of("Europe/Berlin");
78     private static final ZoneId UTC_ZONE_ID = ZoneId.of("UTC");
79     private static final Locale LOCALE = Locale.ENGLISH;
80     private static final ZonedDateTime CURRENT_DATE_TIME =
81             LocalDateTime.of(2019, 12, 10, 10, 10, 10, 500500).atZone(BERLIN_ZONE_ID);
82     private static final ZonedDateTime START_DATE_TIME =
83             CURRENT_DATE_TIME.truncatedTo(ChronoUnit.HOURS);
84     private static final String EVENT_TITLE = "the title";
85     private static final String EVENT_LOCATION = "the location";
86     private static final String EVENT_DESCRIPTION = "the description";
87     private static final String CALENDAR_NAME = "the calendar name";
88     private static final int CALENDAR_COLOR = 0xCAFEBABE;
89     private static final int EVENT_ATTENDEE_STATUS =
90             CalendarContract.Attendees.ATTENDEE_STATUS_ACCEPTED;
91 
92     private final ActivityLifecycleCallback mLifecycleCallback = this::onActivityLifecycleChanged;
93 
94     @Rule
95     public final GrantPermissionRule permissionRule =
96             GrantPermissionRule.grant(Manifest.permission.READ_CALENDAR);
97 
98     private List<Object[]> mTestEventRows;
99 
100     // These can be set in the test thread and read on the main thread.
101     private volatile CountDownLatch mEventChangesLatch;
102 
103     @Before
setUp()104     public void setUp() {
105         ActivityLifecycleMonitorRegistry.getInstance().addLifecycleCallback(mLifecycleCallback);
106         mTestEventRows = new ArrayList<>();
107     }
108 
onActivityLifecycleChanged(Activity activity, Stage stage)109     private void onActivityLifecycleChanged(Activity activity, Stage stage) {
110         if (stage.equals(Stage.PRE_ON_CREATE)) {
111             setActivityDependencies((CarCalendarActivity) activity);
112         } else if (stage.equals(Stage.CREATED)) {
113             observeEventsLiveData((CarCalendarActivity) activity);
114         }
115     }
116 
setActivityDependencies(CarCalendarActivity activity)117     private void setActivityDependencies(CarCalendarActivity activity) {
118         Clock fixedTimeClock = Clock.fixed(CURRENT_DATE_TIME.toInstant(), BERLIN_ZONE_ID);
119         Context context = InstrumentationRegistry.getInstrumentation().getTargetContext();
120         MockContentResolver mockContentResolver = new MockContentResolver(context);
121         TestCalendarContentProvider testCalendarContentProvider =
122                 new TestCalendarContentProvider(context);
123         mockContentResolver.addProvider(CalendarContract.AUTHORITY, testCalendarContentProvider);
124         activity.mDependencies =
125                 new CarCalendarActivity.Dependencies(LOCALE, fixedTimeClock, mockContentResolver);
126     }
127 
observeEventsLiveData(CarCalendarActivity activity)128     private void observeEventsLiveData(CarCalendarActivity activity) {
129         CarCalendarViewModel carCalendarViewModel =
130                 new ViewModelProvider(activity).get(CarCalendarViewModel.class);
131         EventsLiveData eventsLiveData = carCalendarViewModel.getEventsLiveData();
132         mEventChangesLatch = new CountDownLatch(1);
133 
134         // Notifications occur on the main thread.
135         eventsLiveData.observeForever(
136                 new Observer<ImmutableList<Event>>() {
137                     // Ignore the first change event triggered on registration with default value.
138                     boolean mIgnoredFirstChange;
139 
140                     @Override
141                     public void onChanged(ImmutableList<Event> events) {
142                         if (mIgnoredFirstChange) {
143                             // Signal that the events were changed and notified on main thread.
144                             mEventChangesLatch.countDown();
145                         }
146                         mIgnoredFirstChange = true;
147                     }
148                 });
149     }
150 
151     @After
tearDown()152     public void tearDown() {
153         ActivityLifecycleMonitorRegistry.getInstance().removeLifecycleCallback(mLifecycleCallback);
154     }
155 
156     @Test
calendar_titleShows()157     public void calendar_titleShows() {
158         try (ActivityScenario<CarCalendarActivity> ignored =
159                 ActivityScenario.launch(CarCalendarActivity.class)) {
160             onView(withText(R.string.app_name)).check(matches(isDisplayed()));
161         }
162     }
163 
164     @Test
event_displayed()165     public void event_displayed() {
166         mTestEventRows.add(buildTestRow(START_DATE_TIME, 1, EVENT_TITLE, false));
167         try (ActivityScenario<CarCalendarActivity> ignored =
168                 ActivityScenario.launch(CarCalendarActivity.class)) {
169             waitForEventsChange();
170 
171             // Wait for the UI to be updated with changed events.
172             InstrumentationRegistry.getInstrumentation().waitForIdleSync();
173 
174             onView(withText(EVENT_TITLE)).check(matches(isDisplayed()));
175         }
176     }
177 
178     @Test
singleAllDayEvent_notCollapsed()179     public void singleAllDayEvent_notCollapsed() {
180         // All day events are stored in UTC time.
181         ZonedDateTime utcDayStartTime =
182                 START_DATE_TIME.withZoneSameInstant(UTC_ZONE_ID).truncatedTo(ChronoUnit.DAYS);
183 
184         mTestEventRows.add(buildTestRow(utcDayStartTime, 24, EVENT_TITLE, true));
185 
186         try (ActivityScenario<CarCalendarActivity> ignored =
187                 ActivityScenario.launch(CarCalendarActivity.class)) {
188             waitForEventsChange();
189 
190             // Wait for the UI to be updated with changed events.
191             InstrumentationRegistry.getInstrumentation().waitForIdleSync();
192 
193             // A single all-day event should not be collapsible.
194             onView(withId(R.id.expand_collapse_icon)).check(doesNotExist());
195             onView(withText(EVENT_TITLE)).check(matches(isDisplayed()));
196         }
197     }
198 
199     @Test
multipleAllDayEvents_collapsed()200     public void multipleAllDayEvents_collapsed() {
201         mTestEventRows.add(buildTestRowAllDay(EVENT_TITLE));
202         mTestEventRows.add(buildTestRowAllDay("Another all day event"));
203 
204         try (ActivityScenario<CarCalendarActivity> ignored =
205                 ActivityScenario.launch(CarCalendarActivity.class)) {
206             waitForEventsChange();
207 
208             // Wait for the UI to be updated with changed events.
209             InstrumentationRegistry.getInstrumentation().waitForIdleSync();
210 
211             // Multiple all-day events should be collapsed.
212             onView(withId(R.id.expand_collapse_icon)).check(matches(isDisplayed()));
213             onView(withText(EVENT_TITLE)).check(matches(not(isDisplayed())));
214         }
215     }
216 
217     @Test
multipleAllDayEvents_expands()218     public void multipleAllDayEvents_expands() {
219         mTestEventRows.add(buildTestRowAllDay(EVENT_TITLE));
220         mTestEventRows.add(buildTestRowAllDay("Another all day event"));
221 
222         try (ActivityScenario<CarCalendarActivity> ignored =
223                 ActivityScenario.launch(CarCalendarActivity.class)) {
224             waitForEventsChange();
225 
226             // Wait for the UI to be updated with changed events.
227             InstrumentationRegistry.getInstrumentation().waitForIdleSync();
228 
229             // Multiple all-day events should be collapsed.
230             onView(withId(R.id.expand_collapse_icon)).perform(click());
231             InstrumentationRegistry.getInstrumentation().waitForIdleSync();
232             onView(withText(EVENT_TITLE)).check(matches(isDisplayed()));
233         }
234     }
235 
waitForEventsChange()236     private void waitForEventsChange() {
237         try {
238             mEventChangesLatch.await(10, TimeUnit.SECONDS);
239         } catch (InterruptedException e) {
240             throw new RuntimeException(e);
241         }
242     }
243 
244     private class TestCalendarContentProvider extends MockContentProvider {
TestCalendarContentProvider(Context context)245         TestCalendarContentProvider(Context context) {
246             super(context);
247         }
248 
249         @Override
query( Uri uri, String[] projection, Bundle queryArgs, CancellationSignal cancellationSignal)250         public Cursor query(
251                 Uri uri,
252                 String[] projection,
253                 Bundle queryArgs,
254                 CancellationSignal cancellationSignal) {
255             if (uri.toString().startsWith(CalendarContract.Instances.CONTENT_URI.toString())) {
256                 MatrixCursor cursor =
257                         new MatrixCursor(
258                                 new String[] {
259                                     CalendarContract.Instances.TITLE,
260                                     CalendarContract.Instances.ALL_DAY,
261                                     CalendarContract.Instances.BEGIN,
262                                     CalendarContract.Instances.END,
263                                     CalendarContract.Instances.DESCRIPTION,
264                                     CalendarContract.Instances.EVENT_LOCATION,
265                                     CalendarContract.Instances.SELF_ATTENDEE_STATUS,
266                                     CalendarContract.Instances.CALENDAR_COLOR,
267                                     CalendarContract.Instances.CALENDAR_DISPLAY_NAME,
268                                 });
269                 for (Object[] row : mTestEventRows) {
270                     cursor.addRow(row);
271                 }
272                 return cursor;
273             } else if (uri.equals(CalendarContract.Calendars.CONTENT_URI)) {
274                 MatrixCursor cursor = new MatrixCursor(new String[] {" Test name"});
275                 cursor.addRow(new String[] {"Test value"});
276                 return cursor;
277             }
278             throw new IllegalStateException("Unexpected query uri " + uri);
279         }
280     }
281 
buildTestRowAllDay(String title)282     private Object[] buildTestRowAllDay(String title) {
283         // All day events are stored in UTC time.
284         ZonedDateTime utcDayStartTime =
285                 START_DATE_TIME.withZoneSameInstant(UTC_ZONE_ID).truncatedTo(ChronoUnit.DAYS);
286         return buildTestRow(utcDayStartTime, 24, title, true);
287     }
288 
buildTestRow( ZonedDateTime startDateTime, int eventDurationHours, String title, boolean allDay)289     private static Object[] buildTestRow(
290             ZonedDateTime startDateTime, int eventDurationHours, String title, boolean allDay) {
291         return new Object[] {
292             title,
293             allDay ? 1 : 0,
294             startDateTime.toInstant().toEpochMilli(),
295             startDateTime.plusHours(eventDurationHours).toInstant().toEpochMilli(),
296             EVENT_DESCRIPTION,
297             EVENT_LOCATION,
298             EVENT_ATTENDEE_STATUS,
299             CALENDAR_COLOR,
300             CALENDAR_NAME
301         };
302     }
303 }
304