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