diff options
author | Android Build Coastguard Worker <android-build-coastguard-worker@google.com> | 2022-05-11 05:10:03 +0000 |
---|---|---|
committer | Android Build Coastguard Worker <android-build-coastguard-worker@google.com> | 2022-05-11 05:10:03 +0000 |
commit | d6b39497035f43a7803a415ad55e217806bf44d2 (patch) | |
tree | bfd660ff13f094c3282632f35eb1a2bc9feec2cf | |
parent | 5ec54b8a17fe5ec5e648dbf08833e0d448c50cb9 (diff) | |
parent | 8c06fda999803bbb705bc66efa5d38a8061f4c8b (diff) | |
download | Calendar-d6b39497035f43a7803a415ad55e217806bf44d2.tar.gz |
Snap for 8570526 from 8c06fda999803bbb705bc66efa5d38a8061f4c8b to mainline-media-swcodec-release
Change-Id: Iebd63d1a094f63357780307f08d6e650535a3ab9
21 files changed, 400 insertions, 298 deletions
@@ -13,18 +13,24 @@ // limitations under the License. // +package { + default_applicable_licenses: ["Android-Apache-2.0"], +} + android_app { name: "CarCalendarApp", srcs: ["src/**/*.java"], resource_dirs: ["res"], - platform_apis: true, + sdk_version: "current", + target_sdk_version: "29", + min_sdk_version: "28", optimize: { enabled: false, }, dex_preopt: { enabled: false, }, - aaptflags: ["--auto-add-overlay"], + libs: ["android.car-stubs"], static_libs: [ "car-ui-lib", "androidx.lifecycle_lifecycle-extensions", @@ -33,5 +39,4 @@ android_app { "androidx.lifecycle_lifecycle-viewmodel", "guava", ], - libs: ["android.car"], } diff --git a/AndroidManifest.xml b/AndroidManifest.xml index 4921dae..56ce764 100644 --- a/AndroidManifest.xml +++ b/AndroidManifest.xml @@ -18,10 +18,6 @@ xmlns:tools="http://schemas.android.com/tools" package="com.android.car.calendar"> - <uses-sdk - android:minSdkVersion="28" - android:targetSdkVersion="29"/> - <uses-permission android:name="android.permission.READ_CALENDAR" /> <uses-permission android:name="android.permission.CALL_PHONE" /> @@ -29,7 +25,7 @@ android:allowBackup="true" android:icon="@drawable/ic_calendar_sync" android:label="@string/app_name" - android:theme="@style/Theme.CarUi" + android:theme="@style/Theme.CarUi.WithToolbar" android:supportsRtl="true"> <activity android:name=".CarCalendarActivity" @@ -41,15 +37,6 @@ <category android:name="android.intent.category.LAUNCHER"/> </intent-filter> </activity> - - <!-- Work around b/113294940. --> - <provider - android:name="androidx.lifecycle.ProcessLifecycleOwnerInitializer" - tools:replace="android:authorities" - android:authorities="${applicationId}.lifecycle-tests" - android:exported="false" - android:multiprocess="true" /> - </application> </manifest> diff --git a/res/layout/calendar.xml b/res/layout/calendar.xml index 4c31ddc..7fb0bb0 100644 --- a/res/layout/calendar.xml +++ b/res/layout/calendar.xml @@ -13,35 +13,23 @@ See the License for the specific language governing permissions and limitations under the License. --> -<LinearLayout +<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android" - xmlns:app="http://schemas.android.com/apk/res-auto" android:layout_height="match_parent" - android:layout_width="match_parent" - android:orientation="vertical"> - - <com.android.car.ui.toolbar.Toolbar - android:id="@+id/toolbar" + android:layout_width="match_parent"> + <com.android.car.ui.recyclerview.CarUiRecyclerView + android:id="@+id/events" android:layout_width="match_parent" - android:layout_height="wrap_content" - app:title="@string/app_name" + android:layout_height="match_parent" /> - <FrameLayout android:layout_width="match_parent" android:layout_height="match_parent"> - <com.android.car.ui.recyclerview.CarUiRecyclerView - android:id="@+id/events" - android:layout_width="match_parent" - android:layout_height="match_parent" - /> - - <TextView - android:id="@+id/no_events_text" - android:layout_width="match_parent" - android:layout_height="match_parent" - android:layout_margin="@dimen/car_ui_list_item_start_inset" - android:gravity="center" - android:textAppearance="@style/NoEventsText"/> - - </FrameLayout> + <TextView + android:id="@+id/no_events_text" + android:maxWidth="200dp" + android:layout_width="match_parent" + android:layout_height="match_parent" + android:layout_margin="@dimen/car_ui_list_item_start_inset" + android:gravity="center" + android:textAppearance="@style/NoEventsText"/> -</LinearLayout> +</FrameLayout> diff --git a/res/layout/event_item.xml b/res/layout/event_item.xml index 68dda47..5d70907 100644 --- a/res/layout/event_item.xml +++ b/res/layout/event_item.xml @@ -46,7 +46,7 @@ </LinearLayout> <!-- Secondary action icon. --> - <com.android.car.calendar.DrawableStateImageButton + <android.widget.ImageButton android:id="@+id/primary_action_button" android:layout_width="@dimen/car_ui_list_item_height" android:layout_height="@dimen/car_ui_list_item_height" @@ -57,7 +57,7 @@ android:layout_gravity="center"/> <!-- Secondary action icon. --> - <com.android.car.calendar.DrawableStateImageButton + <android.widget.ImageButton android:id="@+id/secondary_action_button" android:layout_width="@dimen/car_ui_list_item_height" android:layout_height="@dimen/car_ui_list_item_height" diff --git a/res/values-it/strings.xml b/res/values-it/strings.xml index 8bca97b..e21316d 100644 --- a/res/values-it/strings.xml +++ b/res/values-it/strings.xml @@ -25,7 +25,7 @@ <skip /> <string name="phone_number_with_pin" msgid="6760582665093825412">"PIN del numero <xliff:g id="NUMBER">%1$s</xliff:g>: <xliff:g id="PIN_0">%2$s</xliff:g>"</string> <plurals name="all_day_title" formatted="false" msgid="7938279592034934265"> + <item quantity="one">%d eventi che durano tutto il giorno</item> <item quantity="other">%d eventi che durano tutto il giorno</item> - <item quantity="one">%d evento che dura tutto il giorno</item> </plurals> </resources> diff --git a/res/values-pt-rPT/strings.xml b/res/values-pt-rPT/strings.xml index 5ce55d2..32888c0 100644 --- a/res/values-pt-rPT/strings.xml +++ b/res/values-pt-rPT/strings.xml @@ -25,7 +25,7 @@ <skip /> <string name="phone_number_with_pin" msgid="6760582665093825412">"<xliff:g id="NUMBER">%1$s</xliff:g> PIN: <xliff:g id="PIN_0">%2$s</xliff:g>"</string> <plurals name="all_day_title" formatted="false" msgid="7938279592034934265"> - <item quantity="other">%d eventos de todo o dia</item> <item quantity="one">%d evento de todo o dia</item> + <item quantity="other">%d eventos de todo o dia</item> </plurals> </resources> diff --git a/res/values-te/strings.xml b/res/values-te/strings.xml index 1e3902c..24f6f3e 100644 --- a/res/values-te/strings.xml +++ b/res/values-te/strings.xml @@ -19,7 +19,7 @@ <string name="app_name" msgid="1756632159204796305">"Calendar"</string> <string name="no_dialler" msgid="1448265958659890310">"డయలర్ అందుబాటులో లేదు"</string> <string name="no_events" msgid="2454460886004475314">"షెడ్యూల్ చేసిన ఈవెంట్లు లేవు. మీరు ఖాళీగా ఉన్నారు!"</string> - <string name="no_calendars" msgid="5059614627806215716">"Calendar ప్రారంభం కావచ్చు, లేదా మీరు సెట్టింగ్లను మీ సహచర యాప్లో చెక్ చేయాలి"</string> + <string name="no_calendars" msgid="5059614627806215716">"క్యాలెండర్ ప్రారంభం కావడానికి సమయం పడుతున్నట్టు ఉంది, లేదా సహచర యాప్లోని మీ సెట్టింగ్లను చెక్ చేసి చూడండి"</string> <string name="all_day_event" msgid="5817490740700803034">"పూర్తి రోజు"</string> <!-- no translation found for phone_number (3879925006862072135) --> <skip /> diff --git a/src/com/android/car/calendar/CarCalendarActivity.java b/src/com/android/car/calendar/CarCalendarActivity.java index 97e2031..94e0db6 100644 --- a/src/com/android/car/calendar/CarCalendarActivity.java +++ b/src/com/android/car/calendar/CarCalendarActivity.java @@ -20,6 +20,7 @@ import android.content.ContentResolver; import android.content.pm.PackageManager; import android.os.Bundle; import android.os.StrictMode; +import android.telephony.TelephonyManager; import android.util.Log; import androidx.annotation.NonNull; @@ -32,6 +33,8 @@ import androidx.lifecycle.ViewModelProvider; import com.android.car.calendar.common.CalendarFormatter; import com.android.car.calendar.common.Dialer; import com.android.car.calendar.common.Navigator; +import com.android.car.ui.core.CarUi; +import com.android.car.ui.toolbar.ToolbarController; import com.google.common.collect.HashMultimap; import com.google.common.collect.Multimap; @@ -55,10 +58,16 @@ public class CarCalendarActivity extends FragmentActivity { super.onCreate(savedInstanceState); maybeEnableStrictMode(); + ToolbarController toolbar = CarUi.requireToolbar(this); + toolbar.setTitle(R.string.app_name); + // Tests can set fake dependencies before onCreate. if (mDependencies == null) { mDependencies = new Dependencies( - Locale.getDefault(), Clock.systemDefaultZone(), getContentResolver()); + Locale.getDefault(), + Clock.systemDefaultZone(), + getContentResolver(), + getSystemService(TelephonyManager.class)); } CarCalendarViewModel carCalendarViewModel = @@ -67,6 +76,7 @@ public class CarCalendarActivity extends FragmentActivity { new CarCalendarViewModelFactory( mDependencies.mResolver, mDependencies.mLocale, + mDependencies.mTelephonyManager, mDependencies.mClock)) .get(CarCalendarViewModel.class); @@ -142,12 +152,18 @@ public class CarCalendarActivity extends FragmentActivity { private static class CarCalendarViewModelFactory implements ViewModelProvider.Factory { private final ContentResolver mResolver; + private final TelephonyManager mTelephonyManager; private final Locale mLocale; private final Clock mClock; - CarCalendarViewModelFactory(ContentResolver resolver, Locale locale, Clock clock) { + CarCalendarViewModelFactory( + ContentResolver resolver, + Locale locale, + TelephonyManager telephonyManager, + Clock clock) { mResolver = resolver; mLocale = locale; + mTelephonyManager = telephonyManager; mClock = clock; } @@ -155,7 +171,7 @@ public class CarCalendarActivity extends FragmentActivity { @NonNull @Override public <T extends ViewModel> T create(@NonNull Class<T> aClass) { - return (T) new CarCalendarViewModel(mResolver, mLocale, mClock); + return (T) new CarCalendarViewModel(mResolver, mLocale, mTelephonyManager, mClock); } } @@ -163,11 +179,17 @@ public class CarCalendarActivity extends FragmentActivity { private final Locale mLocale; private final Clock mClock; private final ContentResolver mResolver; + private final TelephonyManager mTelephonyManager; - Dependencies(Locale locale, Clock clock, ContentResolver resolver) { + Dependencies( + Locale locale, + Clock clock, + ContentResolver resolver, + TelephonyManager telephonyManager) { mLocale = locale; mClock = clock; mResolver = resolver; + mTelephonyManager = telephonyManager; } } } diff --git a/src/com/android/car/calendar/CarCalendarView.java b/src/com/android/car/calendar/CarCalendarView.java index 07b9516..1a63588 100644 --- a/src/com/android/car/calendar/CarCalendarView.java +++ b/src/com/android/car/calendar/CarCalendarView.java @@ -17,7 +17,6 @@ package com.android.car.calendar; import static com.google.common.base.Verify.verify; -import static com.google.common.base.Verify.verifyNotNull; import android.Manifest; import android.util.Log; @@ -25,10 +24,10 @@ import android.view.View; import android.view.ViewGroup; import android.widget.TextView; -import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.lifecycle.Observer; import androidx.recyclerview.widget.RecyclerView; +import androidx.recyclerview.widget.RecyclerView.ViewHolder; import com.android.car.calendar.common.CalendarFormatter; import com.android.car.calendar.common.Dialer; @@ -64,15 +63,8 @@ class CarCalendarView { /** Holds an instance of either {@link LocalDate} or {@link Event} for each item in the list. */ private final List<CalendarItem> mRecyclerViewItems = new ArrayList<>(); - private final RecyclerView.Adapter mAdapter = new EventRecyclerViewAdapter(); - private final Observer<ImmutableList<Event>> mEventsObserver = - events -> { - if (DEBUG) Log.d(TAG, "Events changed"); - updateRecyclerViewItems(events); - - // TODO(jdp) Only change the affected items (DiffUtil) to allow animated changes. - mAdapter.notifyDataSetChanged(); - }; + private final RecyclerView.Adapter<ViewHolder> mAdapter = new EventRecyclerViewAdapter(); + private final Observer<ImmutableList<Event>> mEventsObserver = this::onEventsChanged; CarCalendarView( CarCalendarActivity carCalendarActivity, @@ -102,23 +94,30 @@ class CarCalendarView { private void showWithPermission() { EventsLiveData eventsLiveData = mCarCalendarViewModel.getEventsLiveData(); eventsLiveData.observe(mCarCalendarActivity, mEventsObserver); - updateRecyclerViewItems(verifyNotNull(eventsLiveData.getValue())); + } + + private void onEventsChanged(ImmutableList<Event> events) { + updateRecyclerViewItems(events); + + // TODO(jdp) Only change the affected items (DiffUtil) to allow animated changes. + mAdapter.notifyDataSetChanged(); } /** * If the events list is null there is no calendar data available. If the events list is empty * there is calendar data but no events. */ - private void updateRecyclerViewItems(@Nullable ImmutableList<Event> carCalendarEvents) { + private void updateRecyclerViewItems(@Nullable ImmutableList<Event> events) { + if (DEBUG) Log.d(TAG, "Update events"); LocalDate currentDate = null; mRecyclerViewItems.clear(); - if (carCalendarEvents == null) { + if (events == null) { mNoEventsTextView.setVisibility(View.VISIBLE); mNoEventsTextView.setText(R.string.no_calendars); return; } - if (carCalendarEvents.isEmpty()) { + if (events.isEmpty()) { mNoEventsTextView.setVisibility(View.VISIBLE); mNoEventsTextView.setText(R.string.no_events); return; @@ -130,7 +129,7 @@ class CarCalendarView { // add the event rows after looking at all events for the day. List<CalendarItem> eventItems = null; List<EventCalendarItem> allDayEventItems = null; - for (Event event : carCalendarEvents) { + for (Event event : events) { LocalDate date = event.getDayStartInstant().atZone(ZoneId.systemDefault()).toLocalDate(); @@ -177,17 +176,15 @@ class CarCalendarView { mRecyclerViewItems.addAll(eventItems); } - private class EventRecyclerViewAdapter extends RecyclerView.Adapter { - - @NonNull + private class EventRecyclerViewAdapter extends RecyclerView.Adapter<ViewHolder> { @Override - public RecyclerView.ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { + public ViewHolder onCreateViewHolder(ViewGroup parent, int viewType) { CalendarItem.Type type = CalendarItem.Type.values()[viewType]; return type.createViewHolder(parent); } @Override - public void onBindViewHolder(@NonNull RecyclerView.ViewHolder holder, int position) { + public void onBindViewHolder(ViewHolder holder, int position) { mRecyclerViewItems.get(position).bind(holder); } diff --git a/src/com/android/car/calendar/CarCalendarViewModel.java b/src/com/android/car/calendar/CarCalendarViewModel.java index c8e80ee..337f794 100644 --- a/src/com/android/car/calendar/CarCalendarViewModel.java +++ b/src/com/android/car/calendar/CarCalendarViewModel.java @@ -17,7 +17,9 @@ package com.android.car.calendar; import android.content.ContentResolver; +import android.os.Handler; import android.os.HandlerThread; +import android.telephony.TelephonyManager; import android.util.Log; import androidx.annotation.Nullable; @@ -38,14 +40,17 @@ class CarCalendarViewModel extends ViewModel { private final Clock mClock; private final ContentResolver mResolver; private final Locale mLocale; + private final TelephonyManager mTelephonyManager; @Nullable private EventsLiveData mEventsLiveData; - CarCalendarViewModel(ContentResolver resolver, Locale locale, Clock clock) { + CarCalendarViewModel(ContentResolver resolver, Locale locale, + TelephonyManager telephonyManager, Clock clock) { + mLocale = locale; if (DEBUG) Log.d(TAG, "Creating view model"); mResolver = resolver; + mTelephonyManager = telephonyManager; mHandlerThread.start(); - mLocale = locale; mClock = clock; } @@ -55,9 +60,9 @@ class CarCalendarViewModel extends ViewModel { mEventsLiveData = new EventsLiveData( mClock, - mHandlerThread.getThreadHandler(), + new Handler(mHandlerThread.getLooper()), mResolver, - new EventDescriptions(mLocale), + new EventDescriptions(mLocale, mTelephonyManager), new EventLocations()); } return mEventsLiveData; diff --git a/src/com/android/car/calendar/DrawableStateImageButton.java b/src/com/android/car/calendar/DrawableStateImageButton.java deleted file mode 100644 index 4ebd06e..0000000 --- a/src/com/android/car/calendar/DrawableStateImageButton.java +++ /dev/null @@ -1,72 +0,0 @@ -/* - * 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; - -import android.content.Context; -import android.util.AttributeSet; -import android.widget.ImageButton; - -import androidx.annotation.Nullable; - -import com.android.car.ui.uxr.DrawableStateView; - -/** - * An {@link ImageButton} that implements {@link DrawableStateView}, for allowing additional states - * such as ux restriction. - * - * @see com.android.car.ui.uxr.DrawableStateButton - * - * TODO(jdp) Move this to car-ui-lib. - */ -public class DrawableStateImageButton extends ImageButton implements DrawableStateView { - - private int[] mState; - - public DrawableStateImageButton(Context context) { - super(context); - } - - public DrawableStateImageButton(Context context, @Nullable AttributeSet attrs) { - super(context, attrs); - } - - public DrawableStateImageButton( - Context context, @Nullable AttributeSet attrs, int defStyleAttr) { - super(context, attrs, defStyleAttr); - } - - public DrawableStateImageButton( - Context context, @Nullable AttributeSet attrs, int defStyleAttr, int defStyleRes) { - super(context, attrs, defStyleAttr, defStyleRes); - } - - @Override - public void setDrawableState(int[] state) { - mState = state; - refreshDrawableState(); - } - - @Override - public int[] onCreateDrawableState(int extraSpace) { - if (mState == null) { - return super.onCreateDrawableState(extraSpace); - } else { - return mergeDrawableStates( - super.onCreateDrawableState(extraSpace + mState.length), mState); - } - } -} diff --git a/src/com/android/car/calendar/EventCalendarItem.java b/src/com/android/car/calendar/EventCalendarItem.java index 0642baa..e83c21c 100644 --- a/src/com/android/car/calendar/EventCalendarItem.java +++ b/src/com/android/car/calendar/EventCalendarItem.java @@ -17,7 +17,6 @@ package com.android.car.calendar; import android.Manifest; -import android.annotation.ColorInt; import android.graphics.Paint; import android.graphics.Typeface; import android.graphics.drawable.InsetDrawable; @@ -30,9 +29,11 @@ import android.text.style.StyleSpan; import android.view.LayoutInflater; import android.view.View.OnClickListener; import android.view.ViewGroup; +import android.widget.ImageButton; import android.widget.TextView; import android.widget.Toast; +import androidx.annotation.ColorInt; import androidx.annotation.DrawableRes; import androidx.core.content.ContextCompat; import androidx.recyclerview.widget.RecyclerView; @@ -171,8 +172,8 @@ class EventCalendarItem implements CalendarItem { private final TextView mTitleView; private final TextView mDescriptionView; - private final DrawableStateImageButton mPrimaryActionButton; - private final DrawableStateImageButton mSecondaryActionButton; + private final ImageButton mPrimaryActionButton; + private final ImageButton mSecondaryActionButton; private final int mCalendarIndicatorSize; private final int mCalendarIndicatorPadding; @ColorInt private final int mTimeTextColor; diff --git a/src/com/android/car/calendar/common/Dialer.java b/src/com/android/car/calendar/common/Dialer.java index 889a3c8..df843aa 100644 --- a/src/com/android/car/calendar/common/Dialer.java +++ b/src/com/android/car/calendar/common/Dialer.java @@ -26,6 +26,8 @@ import android.util.Log; import com.google.common.base.MoreObjects; import com.google.common.base.Strings; +import java.util.Objects; + import javax.annotation.Nullable; /** Calls the default dialer with an optional access code. */ @@ -94,5 +96,18 @@ public class Dialer { .add("mAccess", mAccess) .toString(); } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + NumberAndAccess that = (NumberAndAccess) o; + return mNumber.equals(that.mNumber) && Objects.equals(mAccess, that.mAccess); + } + + @Override + public int hashCode() { + return Objects.hash(mNumber, mAccess); + } } } diff --git a/src/com/android/car/calendar/common/Event.java b/src/com/android/car/calendar/common/Event.java index 4395d33..6f88717 100644 --- a/src/com/android/car/calendar/common/Event.java +++ b/src/com/android/car/calendar/common/Event.java @@ -20,6 +20,9 @@ import com.android.car.calendar.common.Dialer.NumberAndAccess; import java.time.Duration; import java.time.Instant; +import java.util.Objects; + +import javax.annotation.Nullable; /** * An immutable value representing a calendar event. Should contain only details that are relevant @@ -34,9 +37,7 @@ public final class Event { NONE, } - /** - * The details required for display of the calendar indicator. - */ + /** The details required for display of the calendar indicator. */ public static class CalendarDetails { private final String mName; private final int mColor; @@ -53,6 +54,19 @@ public final class Event { public String getName() { return mName; } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + CalendarDetails that = (CalendarDetails) o; + return mColor == that.mColor && mName.equals(that.mName); + } + + @Override + public int hashCode() { + return Objects.hash(mName, mColor); + } } private final boolean mAllDay; @@ -62,8 +76,8 @@ public final class Event { private final Instant mDayEndInstant; private final String mTitle; private final Status mStatus; - private final String mLocation; - private final NumberAndAccess mNumberAndAccess; + @Nullable private final String mLocation; + @Nullable private final NumberAndAccess mNumberAndAccess; private final CalendarDetails mCalendarDetails; Event( @@ -74,8 +88,8 @@ public final class Event { Instant dayEndInstant, String title, Status status, - String location, - NumberAndAccess numberAndAccess, + @Nullable String location, + @Nullable NumberAndAccess numberAndAccess, CalendarDetails calendarDetails) { mAllDay = allDay; mStartInstant = startInstant; @@ -109,6 +123,7 @@ public final class Event { return mTitle; } + @Nullable public NumberAndAccess getNumberAndAccess() { return mNumberAndAccess; } @@ -117,6 +132,7 @@ public final class Event { return mCalendarDetails; } + @Nullable public String getLocation() { return mLocation; } @@ -132,4 +148,36 @@ public final class Event { public Duration getDuration() { return Duration.between(getStartInstant(), getEndInstant()); } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + Event event = (Event) o; + return mAllDay == event.mAllDay + && mStartInstant.equals(event.mStartInstant) + && mDayStartInstant.equals(event.mDayStartInstant) + && mEndInstant.equals(event.mEndInstant) + && mDayEndInstant.equals(event.mDayEndInstant) + && mTitle.equals(event.mTitle) + && mStatus == event.mStatus + && Objects.equals(mLocation, event.mLocation) + && Objects.equals(mNumberAndAccess, event.mNumberAndAccess) + && mCalendarDetails.equals(event.mCalendarDetails); + } + + @Override + public int hashCode() { + return Objects.hash( + mAllDay, + mStartInstant, + mDayStartInstant, + mEndInstant, + mDayEndInstant, + mTitle, + mStatus, + mLocation, + mNumberAndAccess, + mCalendarDetails); + } } diff --git a/src/com/android/car/calendar/common/EventDescriptions.java b/src/com/android/car/calendar/common/EventDescriptions.java index e6aad5b..8d2030f 100644 --- a/src/com/android/car/calendar/common/EventDescriptions.java +++ b/src/com/android/car/calendar/common/EventDescriptions.java @@ -16,20 +16,15 @@ package com.android.car.calendar.common; -import static com.android.i18n.phonenumbers.PhoneNumberUtil.PhoneNumberFormat.INTERNATIONAL; -import static com.android.i18n.phonenumbers.PhoneNumberUtil.ValidationResult.IS_POSSIBLE; -import static com.android.i18n.phonenumbers.PhoneNumberUtil.ValidationResult.IS_POSSIBLE_LOCAL_ONLY; -import static com.android.i18n.phonenumbers.PhoneNumberUtil.ValidationResult.TOO_LONG; - import static com.google.common.base.Verify.verifyNotNull; import android.net.Uri; +import android.telephony.PhoneNumberUtils; +import android.telephony.TelephonyManager; import com.android.car.calendar.common.Dialer.NumberAndAccess; -import com.android.i18n.phonenumbers.NumberParseException; -import com.android.i18n.phonenumbers.PhoneNumberUtil; -import com.android.i18n.phonenumbers.Phonenumber; +import com.google.common.base.Strings; import com.google.common.collect.ImmutableList; import java.util.LinkedHashMap; @@ -45,37 +40,45 @@ import javax.annotation.Nullable; public class EventDescriptions { // Requires a phone number to include only numbers, spaces and dash, optionally a leading "+". - // The number must be at least 6 characters. + // The number must be at least 6 characters and can contain " " or "-" but end with a digit. // The access code must be at least 3 characters. // The number and the access to include "pin" or "code" between the numbers. private static final Pattern PHONE_PIN_PATTERN = Pattern.compile( - "(\\+?[\\d -]{6,})(?:.*\\b(?:PIN|code)\\b.*?([\\d,;#*]{3,}))?", + "(\\+?[\\d -]{6,}\\d)(?:.*\\b(?:PIN|code)\\b.*?([\\d,;#*]{3,}))?", Pattern.CASE_INSENSITIVE); // Matches numbers in the encoded format "<tel: ... >". private static final Pattern TEL_PIN_PATTERN = Pattern.compile("<tel:(\\+?[\\d -]{6,})([\\d,;#*]{3,})?>"); - private static final PhoneNumberUtil PHONE_NUMBER_UTIL = PhoneNumberUtil.getInstance(); - // Ensure numbers are over 5 digits to reduce false positives. - private static final int MIN_NATIONAL_NUMBER = 10_000; + private static final int MIN_DIGITS = 5; - private final Locale mLocale; + private final String mCountryIso; - public EventDescriptions(Locale locale) { - mLocale = locale; + public EventDescriptions(Locale locale, TelephonyManager telephonyManager) { + String networkCountryIso = telephonyManager.getNetworkCountryIso().toUpperCase(); + if (!Strings.isNullOrEmpty(networkCountryIso)) { + mCountryIso = networkCountryIso; + } else { + mCountryIso = locale.getCountry(); + } } /** Find conference call data embedded in the description. */ public List<NumberAndAccess> extractNumberAndPins(String descriptionText) { String decoded = Uri.decode(descriptionText); + // Use a map keyed by number to act like a set and only add a single number. Map<String, NumberAndAccess> results = new LinkedHashMap<>(); addMatchedNumbers(decoded, results, PHONE_PIN_PATTERN); + + // Add the most restrictive precise format last to replace others with the same number. addMatchedNumbers(decoded, results, TEL_PIN_PATTERN); - return ImmutableList.copyOf(results.values()); + + // Reverse order so the most precise format is first. + return ImmutableList.copyOf(results.values()).reverse(); } private void addMatchedNumbers( @@ -93,27 +96,18 @@ public class EventDescriptions { private NumberAndAccess validNumberAndAccess(Matcher phoneFormatMatcher) { String number = verifyNotNull(phoneFormatMatcher.group(1)); String access = phoneFormatMatcher.group(2); - try { - Phonenumber.PhoneNumber phoneNumber = - PHONE_NUMBER_UTIL.parse(number, mLocale.getCountry()); - PhoneNumberUtil.ValidationResult result = - PHONE_NUMBER_UTIL.isPossibleNumberWithReason(phoneNumber); - if (isAcceptableResult(result)) { - if (phoneNumber.getNationalNumber() < MIN_NATIONAL_NUMBER) { - return null; - } - String formatted = PHONE_NUMBER_UTIL.format(phoneNumber, INTERNATIONAL); - return new NumberAndAccess(formatted, access); - } - } catch (NumberParseException e) { - // Ignore invalid numbers. + + // Ensure that there are a minimum number of digits to reduce false positives. + String onlyDigits = number.replaceAll("\\D", ""); + if (onlyDigits.length() < MIN_DIGITS) { + return null; } - return null; - } - private boolean isAcceptableResult(PhoneNumberUtil.ValidationResult result) { - // The result can be too long and still valid because the US locale is used by default - // which does not accept valid long numbers from other regions. - return result == IS_POSSIBLE || result == IS_POSSIBLE_LOCAL_ONLY || result == TOO_LONG; + // Keep local numbers in local format which the dialer can make more sense of. + String formatted = PhoneNumberUtils.formatNumber(number, mCountryIso); + if (formatted == null) { + return null; + } + return new NumberAndAccess(formatted, access); } } diff --git a/src/com/android/car/calendar/common/EventsLiveData.java b/src/com/android/car/calendar/common/EventsLiveData.java index 12c91e7..92ae0bb 100644 --- a/src/com/android/car/calendar/common/EventsLiveData.java +++ b/src/com/android/car/calendar/common/EventsLiveData.java @@ -31,7 +31,9 @@ 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; @@ -44,6 +46,7 @@ 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; @@ -53,12 +56,17 @@ import javax.annotation.Nullable; * Provider</a>. * * <p>While in the active state the content provider is observed for changes. + * + * <p>When the value given to the observer is null it signals that there are no calendars. */ public class EventsLiveData extends LiveData<ImmutableList<Event>> { 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> EVENT_COMPARATOR = Comparator.comparing(Event::getDayStartInstant).thenComparing(Event::getTitle); @@ -68,19 +76,22 @@ public class EventsLiveData extends LiveData<ImmutableList<Event>> { 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) { - super(ImmutableList.of()); mClock = clock; mBackgroundHandler = backgroundHandler; mContentResolver = contentResolver; @@ -89,8 +100,16 @@ public class EventsLiveData extends LiveData<ImmutableList<Event>> { } /** Refreshes the event instances and sets the new value which notifies observers. */ - private void update() { - postValue(getEventsUntilTomorrow()); + private void updateIfChanged() { + Log.d(TAG, "Update if changed"); + ImmutableList<Event> latest = getEventsUntilTomorrow(); + ImmutableList<Event> 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. */ @@ -167,7 +186,7 @@ public class EventsLiveData extends LiveData<ImmutableList<Event>> { @Override public void onChange(boolean selfChange) { if (DEBUG) Log.d(TAG, "Events changed"); - update(); + updateWithDelay(); } }; cursor.setNotificationUri(mContentResolver, eventInstanceUri); @@ -176,6 +195,13 @@ public class EventsLiveData extends LiveData<ImmutableList<Event>> { 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<Event> createEventsForRow( Cursor eventInstancesCursor, EventDescriptions eventDescriptions) { @@ -262,13 +288,14 @@ public class EventsLiveData extends LiveData<ImmutableList<Event>> { if (DEBUG) Log.d(TAG, "Live data inactive"); mBackgroundHandler.post(this::cancelScheduledUpdate); mBackgroundHandler.post(this::tearDownCursor); + mValueUpdated = false; } - /** Calls {@link #update()} every minute to keep the displayed time range correct. */ + /** 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()) { - update(); + updateIfChanged(); ZonedDateTime now = ZonedDateTime.now(mClock); ZonedDateTime truncatedNowTime = now.truncatedTo(MINUTES); ZonedDateTime updateTime = truncatedNowTime.plus(1, MINUTES); diff --git a/tests/ui/Android.bp b/tests/ui/Android.bp index 829e312..0480d95 100644 --- a/tests/ui/Android.bp +++ b/tests/ui/Android.bp @@ -13,6 +13,10 @@ // limitations under the License. // +package { + default_applicable_licenses: ["Android-Apache-2.0"], +} + android_test { name: "CarCalendarUiTests", srcs: ["src/**/*.java"], diff --git a/tests/ui/src/com/android/car/calendar/CarCalendarUiTest.java b/tests/ui/src/com/android/car/calendar/CarCalendarUiTest.java index 5c7883c..e591a80 100644 --- a/tests/ui/src/com/android/car/calendar/CarCalendarUiTest.java +++ b/tests/ui/src/com/android/car/calendar/CarCalendarUiTest.java @@ -35,6 +35,7 @@ import android.net.Uri; import android.os.Bundle; import android.os.CancellationSignal; import android.provider.CalendarContract; +import android.telephony.TelephonyManager; import android.test.mock.MockContentProvider; import android.test.mock.MockContentResolver; @@ -97,6 +98,9 @@ public class CarCalendarUiTest { private List<Object[]> mTestEventRows; + // If set to true fake dependencies will not be set and the real provider will be used. + private boolean mDoNotSetFakeDependencies; + // These can be set in the test thread and read on the main thread. private volatile CountDownLatch mEventChangesLatch; @@ -104,9 +108,12 @@ public class CarCalendarUiTest { public void setUp() { ActivityLifecycleMonitorRegistry.getInstance().addLifecycleCallback(mLifecycleCallback); mTestEventRows = new ArrayList<>(); + mDoNotSetFakeDependencies = false; } private void onActivityLifecycleChanged(Activity activity, Stage stage) { + if (mDoNotSetFakeDependencies) return; + if (stage.equals(Stage.PRE_ON_CREATE)) { setActivityDependencies((CarCalendarActivity) activity); } else if (stage.equals(Stage.CREATED)) { @@ -122,7 +129,8 @@ public class CarCalendarUiTest { new TestCalendarContentProvider(context); mockContentResolver.addProvider(CalendarContract.AUTHORITY, testCalendarContentProvider); activity.mDependencies = - new CarCalendarActivity.Dependencies(LOCALE, fixedTimeClock, mockContentResolver); + new CarCalendarActivity.Dependencies(LOCALE, fixedTimeClock, mockContentResolver, + activity.getSystemService(TelephonyManager.class)); } private void observeEventsLiveData(CarCalendarActivity activity) { @@ -154,9 +162,18 @@ public class CarCalendarUiTest { } @Test - public void calendar_titleShows() { + public void withFakeDependencies_titleShows() { try (ActivityScenario<CarCalendarActivity> ignored = - ActivityScenario.launch(CarCalendarActivity.class)) { + ActivityScenario.launch(CarCalendarActivity.class)) { + onView(withText(R.string.app_name)).check(matches(isDisplayed())); + } + } + + @Test + public void withoutFakeDependencies_titleShows() { + mDoNotSetFakeDependencies = true; + try (ActivityScenario<CarCalendarActivity> ignored = + ActivityScenario.launch(CarCalendarActivity.class)) { onView(withText(R.string.app_name)).check(matches(isDisplayed())); } } diff --git a/tests/unit/Android.bp b/tests/unit/Android.bp index 42c7ee0..0108383 100644 --- a/tests/unit/Android.bp +++ b/tests/unit/Android.bp @@ -13,6 +13,10 @@ // limitations under the License. // +package { + default_applicable_licenses: ["Android-Apache-2.0"], +} + android_test { name: "CarCalendarUnitTests", srcs: ["src/**/*.java"], diff --git a/tests/unit/src/com/android/car/calendar/common/EventDescriptionsTest.java b/tests/unit/src/com/android/car/calendar/common/EventDescriptionsTest.java index 358e9cf..bea029f 100644 --- a/tests/unit/src/com/android/car/calendar/common/EventDescriptionsTest.java +++ b/tests/unit/src/com/android/car/calendar/common/EventDescriptionsTest.java @@ -18,28 +18,36 @@ package com.android.car.calendar.common; import static com.google.common.truth.Truth.assertThat; +import static org.mockito.Mockito.when; + import android.net.Uri; +import android.telephony.TelephonyManager; import com.google.common.collect.Iterables; import org.junit.Before; import org.junit.Test; +import org.mockito.Mockito; 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 static final String COUNTRY_ISO_CODE = "DE"; + private EventDescriptions mEventDescriptions; + private TelephonyManager mMockTelephonyManager; @Before public void setUp() { - mEventDescriptions = new EventDescriptions(Locale.GERMANY); + mMockTelephonyManager = Mockito.mock(TelephonyManager.class); + when(mMockTelephonyManager.getNetworkCountryIso()).thenReturn(COUNTRY_ISO_CODE); + mEventDescriptions = new EventDescriptions(Locale.GERMANY, mMockTelephonyManager); } @Test @@ -48,7 +56,7 @@ public class EventDescriptionsTest { mEventDescriptions.extractNumberAndPins(LOCAL_NUMBER); assertThat(numberAndAccesses).isNotEmpty(); Dialer.NumberAndAccess numberAndAccess = Iterables.getFirst(numberAndAccesses, null); - assertThat(numberAndAccess.getNumber()).isEqualTo(INTERNATIONAL_NUMBER); + assertThat(numberAndAccess.getNumber()).isEqualTo(LOCAL_NUMBER); } @Test @@ -62,9 +70,10 @@ public class EventDescriptionsTest { @Test public void extractNumberAndPin_internationalNumberWithDifferentLocale_resultIsInternational() { - mEventDescriptions = new EventDescriptions(Locale.FRANCE); + EventDescriptions eventDescriptions = + new EventDescriptions(Locale.FRANCE, mMockTelephonyManager); List<Dialer.NumberAndAccess> numberAndAccesses = - mEventDescriptions.extractNumberAndPins(INTERNATIONAL_NUMBER); + eventDescriptions.extractNumberAndPins(INTERNATIONAL_NUMBER); assertThat(numberAndAccesses).isNotEmpty(); Dialer.NumberAndAccess numberAndAccess = Iterables.getFirst(numberAndAccesses, null); assertThat(numberAndAccess.getNumber()).isEqualTo(INTERNATIONAL_NUMBER); @@ -105,8 +114,8 @@ public class EventDescriptionsTest { List<Dialer.NumberAndAccess> numberAndAccesses = mEventDescriptions.extractNumberAndPins(input); - // The local number is valid but repeated so only included once. - assertThat(numberAndAccesses).hasSize(1); + // Keep all variations of a base number. + assertThat(numberAndAccesses).hasSize(3); } @Test diff --git a/tests/unit/src/com/android/car/calendar/common/EventsLiveDataTest.java b/tests/unit/src/com/android/car/calendar/common/EventsLiveDataTest.java index ff00e8d..79b5e29 100644 --- a/tests/unit/src/com/android/car/calendar/common/EventsLiveDataTest.java +++ b/tests/unit/src/com/android/car/calendar/common/EventsLiveDataTest.java @@ -21,6 +21,8 @@ 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.timeout; +import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import static java.time.temporal.ChronoUnit.HOURS; @@ -138,12 +140,11 @@ public class EventsLiveDataTest { @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()); + // Observing triggers content to be read. + mEventsLiveData.observeForever((unused) -> { /* Do nothing */ }); // Wait for the data to be read on the background thread. - latch.await(5, TimeUnit.SECONDS); + mTestContentProvider.awaitCalendarQuery(); assertThat(mTestContentProvider.mTestEventCursor).isNotNull(); } @@ -151,49 +152,63 @@ public class EventsLiveDataTest { @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()); + // Observing triggers content to be read. + mEventsLiveData.observeForever((unused) -> { /* Do nothing */ }); // Wait for the data to be read on the background thread. - latch.await(5, TimeUnit.SECONDS); + mTestContentProvider.awaitCalendarQuery(); - assertThat(mTestContentProvider.mTestEventCursor.mLastContentObserver).isNotNull(); + awaitAndAssertDone(mTestContentProvider.mTestEventCursor.mRegisterContentObserverLatch); } @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<ImmutableList<Event>> observer = (value) -> latch.countDown(); - mEventsLiveData.observeForever(observer); + public void addObserver_observerCalled() throws InterruptedException { + // Observing triggers content to be read. + Observer<ImmutableList<Event>> mockObserver = mock(Observer.class); + runOnMain(() -> mEventsLiveData.observeForever(mockObserver)); - // Wait for the data to be read on the background thread. - latch.await(5, TimeUnit.SECONDS); + // TODO(jdp) This method of verifying an async behaviour is easier to read. + verify(mockObserver, timeout(1000).times(1)).onChanged(any()); + } - final CountDownLatch latch2 = new CountDownLatch(1); - mEventsLiveData.removeObserver(observer); + @Test + public void addTwoObservers_bothObserversCalled() throws InterruptedException { + // Observing triggers content to be read. + Observer<ImmutableList<Event>> mockObserver1 = mock(Observer.class); + runOnMain(() -> mEventsLiveData.observeForever(mockObserver1)); + Observer<ImmutableList<Event>> mockObserver2 = mock(Observer.class); + runOnMain(() -> mEventsLiveData.observeForever(mockObserver2)); + + verify(mockObserver1, timeout(1000).times(1)).onChanged(any()); + verify(mockObserver2, timeout(1000).times(1)).onChanged(any()); + } - // Wait for the observer to be unregistered on the background thread. - latch2.await(5, TimeUnit.SECONDS); + @Test + public void removeObserver_contentNotObserved() throws InterruptedException { + // Observing triggers content to be read. + Observer<ImmutableList<Event>> observer = (unused) -> { /* Do nothing */ }; + runOnMain(() -> mEventsLiveData.observeForever(observer)); + + // Wait for the data to be read on the background thread. + mTestContentProvider.awaitCalendarQuery(); - assertThat(mTestContentProvider.mTestEventCursor.mLastContentObserver).isNull(); + awaitAndAssertDone(mTestContentProvider.mTestEventCursor.mRegisterContentObserverLatch); + runOnMain(() -> mEventsLiveData.removeObserver(observer)); + awaitAndAssertDone(mTestContentProvider.mTestEventCursor.mUnregisterContentObserverLatch); } @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); + // Expect onChanged to be called for when the data is read. + CountDownLatch latch = new CountDownLatch(1); // 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); + awaitAndAssertDone(latch); ImmutableList<Event> events = mEventsLiveData.getValue(); assertThat(events).isNotNull(); @@ -213,12 +228,14 @@ public class EventsLiveDataTest { } @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); + public void notifyDataChange_dataNotChanged_onChangedNotCalled() throws InterruptedException { + mTestContentProvider.addRow(buildTestRow()); + + // Expect onChanged to be called for when the data is read. + CountDownLatch initializeCountdownLatch = new CountDownLatch(1); - // Expect the same init callbacks as above but with an extra when the data is updated. - CountDownLatch changeCountdownLatch = new CountDownLatch(3); + // Expect the same callback as above but with an extra when the data is updated. + CountDownLatch changeCountdownLatch = new CountDownLatch(2); // Must add observer on main thread. runOnMain( @@ -231,32 +248,54 @@ public class EventsLiveDataTest { })); // Wait for the data to be read on the background thread. - initializeCountdownLatch.await(5, TimeUnit.SECONDS); + awaitAndAssertDone(initializeCountdownLatch); - // Signal that the content has changed. + // Signal that the content has changed but do not update the data. 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); + awaitAndAssertNotDone(changeCountdownLatch); } @Test - public void addObserver_updateScheduled() throws InterruptedException { - mTestHandler.setExpectedMessageCount(2); + public void notifyDataChange_dataChanged_onChangedCalled() throws InterruptedException { + mTestContentProvider.addRow(buildTestRow()); + + // Expect onChanged to be called for when the data is read. + CountDownLatch initializeCountdownLatch = new CountDownLatch(1); + + // Expect the same callback as above but with an extra when the data is updated. + CountDownLatch changeCountdownLatch = new CountDownLatch(2); // Must add observer on main thread. runOnMain( () -> mEventsLiveData.observeForever( + // Count down both latches when data is changed. (value) -> { - /* Do nothing */ + initializeCountdownLatch.countDown(); + changeCountdownLatch.countDown(); })); - mTestHandler.awaitExpectedMessages(5); + // Wait for the data to be read on the background thread. + awaitAndAssertDone(initializeCountdownLatch); + + // Change the data and signal that the content has changed. + mTestContentProvider.addRow(buildTestRowWithTitle("Another event")); + mTestContentProvider.mTestEventCursor.signalDataChanged(); + + // Wait for the changed data to be read on the background thread. + awaitAndAssertDone(changeCountdownLatch); + } + + @Test + public void addObserver_updateScheduled() throws InterruptedException { + mTestHandler.setExpectedMessageCount(2); + + // Must add observer on main thread. + runOnMain(() -> mEventsLiveData.observeForever((unused) -> { /* Do nothing */ })); + + mTestHandler.awaitExpectedMessages(); // Show that a message was scheduled for the future. assertThat(mTestHandler.mLastUptimeMillis).isAtLeast(SystemClock.uptimeMillis()); @@ -265,13 +304,14 @@ public class EventsLiveDataTest { @Test public void noCalendars_valueNull() throws InterruptedException { mTestContentProvider.mAddFakeCalendar = false; + mTestContentProvider.addRow(buildTestRow()); - // Expect onChanged to be called for when we start to observe and when the data is read. - CountDownLatch latch = new CountDownLatch(2); + // Expect onChanged to be called for when the data is read. + CountDownLatch latch = new CountDownLatch(1); runOnMain(() -> mEventsLiveData.observeForever((value) -> latch.countDown())); // Wait for the data to be read on the background thread. - latch.await(5, TimeUnit.SECONDS); + awaitAndAssertDone(latch); assertThat(mEventsLiveData.getValue()).isNull(); } @@ -280,15 +320,9 @@ public class EventsLiveDataTest { @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(); + mEventsLiveData.observeForever((unused) -> { /* Do nothing */ }); + mTestContentProvider.awaitCalendarQuery(); + awaitAndAssertDone(mTestContentProvider.mTestEventCursor.mRegisterContentObserverLatch); } @Test @@ -296,13 +330,12 @@ public class EventsLiveDataTest { // 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); + CountDownLatch latch = new CountDownLatch(1); runOnMain(() -> mEventsLiveData.observeForever((value) -> latch.countDown())); // Wait for the data to be read on the background thread. - latch.await(5, TimeUnit.SECONDS); + awaitAndAssertDone(latch); // Expect an event for the 2 parts of the split event instance. assertThat(mEventsLiveData.getValue()).hasSize(2); @@ -314,13 +347,12 @@ public class EventsLiveDataTest { 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); + CountDownLatch latch = new CountDownLatch(1); runOnMain(() -> mEventsLiveData.observeForever((value) -> latch.countDown())); // Wait for the data to be read on the background thread. - latch.await(5, TimeUnit.SECONDS); + awaitAndAssertDone(latch); Event middlePartEvent = mEventsLiveData.getValue().get(1); @@ -338,13 +370,12 @@ public class EventsLiveDataTest { 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); + CountDownLatch latch = new CountDownLatch(1); runOnMain(() -> mEventsLiveData.observeForever((value) -> latch.countDown())); // Wait for the data to be read on the background thread. - latch.await(5, TimeUnit.SECONDS); + awaitAndAssertDone(latch); ImmutableList<Event> events = mEventsLiveData.getValue(); @@ -357,17 +388,17 @@ public class EventsLiveDataTest { @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")); + mTestContentProvider.addRow(buildTestRowWithTitle("Title B")); + mTestContentProvider.addRow(buildTestRowWithTitle("Title A")); + mTestContentProvider.addRow(buildTestRowWithTitle("Title C")); - // Expect onChanged to be called for when we start to observe and when the data is read. - CountDownLatch latch = new CountDownLatch(2); + // Expect onChanged to be called for when the data is read. + CountDownLatch latch = new CountDownLatch(1); runOnMain(() -> mEventsLiveData.observeForever((value) -> latch.countDown())); // Wait for the data to be read on the background thread. - latch.await(5, TimeUnit.SECONDS); + awaitAndAssertDone(latch); ImmutableList<Event> events = mEventsLiveData.getValue(); @@ -383,13 +414,13 @@ public class EventsLiveDataTest { 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); + // Expect onChanged to be called when the data is read. + CountDownLatch latch = new CountDownLatch(1); runOnMain(() -> mEventsLiveData.observeForever((value) -> latch.countDown())); // Wait for the data to be read on the background thread. - latch.await(5, TimeUnit.SECONDS); + awaitAndAssertDone(latch); ImmutableList<Event> events = mEventsLiveData.getValue(); @@ -407,22 +438,35 @@ public class EventsLiveDataTest { // 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); + // Expect onChanged to be called for when the data is read. + CountDownLatch latch = new CountDownLatch(1); runOnMain(() -> mEventsLiveData.observeForever((value) -> latch.countDown())); // Wait for the data to be read on the background thread. - latch.await(5, TimeUnit.SECONDS); + awaitAndAssertDone(latch); // Show that the event is included even though its end time is before the current time. assertThat(mEventsLiveData.getValue()).isNotEmpty(); } + private void runOnMain(Runnable runnable) { + InstrumentationRegistry.getInstrumentation().runOnMainSync(runnable); + } + + private static void awaitAndAssertDone(CountDownLatch latch) throws InterruptedException { + assertThat(latch.await(2, TimeUnit.SECONDS)).isTrue(); + } + + private static void awaitAndAssertNotDone(CountDownLatch latch) throws InterruptedException { + assertThat(latch.await(2, TimeUnit.SECONDS)).isFalse(); + } + private static class TestContentProvider extends MockContentProvider { TestEventCursor mTestEventCursor; boolean mAddFakeCalendar = true; List<Object[]> mEventRows = new ArrayList<>(); + CountDownLatch mCalendarQueryLatch = new CountDownLatch(1); TestContentProvider(Context context) { super(context); @@ -449,14 +493,20 @@ public class EventsLiveDataTest { if (mAddFakeCalendar) { calendarsCursor.addRow(new String[] {"Test value"}); } + mCalendarQueryLatch.countDown(); return calendarsCursor; } throw new IllegalStateException("Unexpected query uri " + uri); } + void awaitCalendarQuery() throws InterruptedException { + awaitAndAssertDone(mCalendarQueryLatch); + } + static class TestEventCursor extends MatrixCursor { final Uri mUri; - ContentObserver mLastContentObserver; + CountDownLatch mRegisterContentObserverLatch = new CountDownLatch(1); + CountDownLatch mUnregisterContentObserverLatch = new CountDownLatch(1); TestEventCursor(Uri uri) { super( @@ -477,13 +527,13 @@ public class EventsLiveDataTest { @Override public void registerContentObserver(ContentObserver observer) { super.registerContentObserver(observer); - mLastContentObserver = observer; + mRegisterContentObserverLatch.countDown(); } @Override public void unregisterContentObserver(ContentObserver observer) { super.unregisterContentObserver(observer); - mLastContentObserver = null; + mUnregisterContentObserverLatch.countDown(); } void signalDataChanged() { @@ -519,8 +569,8 @@ public class EventsLiveDataTest { mCountDownLatch = new CountDownLatch(expectedMessageCount); } - void awaitExpectedMessages(int seconds) throws InterruptedException { - mCountDownLatch.await(seconds, TimeUnit.SECONDS); + void awaitExpectedMessages() throws InterruptedException { + awaitAndAssertDone(mCountDownLatch); } @Override @@ -573,10 +623,7 @@ public class EventsLiveDataTest { } static long addHoursAndTruncate(ZonedDateTime dateTime, int hours) { - return dateTime.truncatedTo(HOURS) - .plus(Duration.ofHours(hours)) - .toInstant() - .toEpochMilli(); + return dateTime.truncatedTo(HOURS).plus(Duration.ofHours(hours)).toInstant().toEpochMilli(); } static Object[] buildTestRowWithDuration(ZonedDateTime startDateTime, int eventDurationHours) { @@ -588,8 +635,12 @@ public class EventsLiveDataTest { return buildTestRowWithDuration(startDateTime, 24, EVENT_TITLE, true); } - static Object[] buildTestRowWithTitle(ZonedDateTime startDateTime, String title) { - return buildTestRowWithDuration(startDateTime, 1, title, EVENT_ALL_DAY); + static Object[] buildTestRowWithTitle(String title) { + return buildTestRowWithDuration(CURRENT_DATE_TIME, 1, title, EVENT_ALL_DAY); + } + + static Object[] buildTestRow() { + return buildTestRowWithDuration(CURRENT_DATE_TIME, 1, EVENT_TITLE, EVENT_ALL_DAY); } static Object[] buildTestRowWithDuration( |