From 8006d808a63752a7f2318fc71e7ca1fc09d67c78 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 Test: clean build from root still works Change-Id: I64cca6684ba132f47308911bdc73f35fe2176502 --- src/com/android/car/calendar/AllDayEventsItem.java | 111 ++++++++ src/com/android/car/calendar/CalendarItem.java | 60 ++++ .../android/car/calendar/CarCalendarActivity.java | 168 +++++++++++ src/com/android/car/calendar/CarCalendarView.java | 204 +++++++++++++ .../android/car/calendar/CarCalendarViewModel.java | 71 +++++ .../car/calendar/DrawableStateImageButton.java | 72 +++++ .../android/car/calendar/EventCalendarItem.java | 314 +++++++++++++++++++++ .../android/car/calendar/TitleCalendarItem.java | 65 +++++ .../car/calendar/common/CalendarFormatter.java | 115 ++++++++ src/com/android/car/calendar/common/Dialer.java | 98 +++++++ src/com/android/car/calendar/common/Event.java | 135 +++++++++ .../car/calendar/common/EventDescriptions.java | 119 ++++++++ .../car/calendar/common/EventLocations.java | 30 ++ .../car/calendar/common/EventsLiveData.java | 305 ++++++++++++++++++++ src/com/android/car/calendar/common/Navigator.java | 56 ++++ 15 files changed, 1923 insertions(+) create mode 100644 src/com/android/car/calendar/AllDayEventsItem.java create mode 100644 src/com/android/car/calendar/CalendarItem.java create mode 100644 src/com/android/car/calendar/CarCalendarActivity.java create mode 100644 src/com/android/car/calendar/CarCalendarView.java create mode 100644 src/com/android/car/calendar/CarCalendarViewModel.java create mode 100644 src/com/android/car/calendar/DrawableStateImageButton.java create mode 100644 src/com/android/car/calendar/EventCalendarItem.java create mode 100644 src/com/android/car/calendar/TitleCalendarItem.java create mode 100644 src/com/android/car/calendar/common/CalendarFormatter.java create mode 100644 src/com/android/car/calendar/common/Dialer.java create mode 100644 src/com/android/car/calendar/common/Event.java create mode 100644 src/com/android/car/calendar/common/EventDescriptions.java create mode 100644 src/com/android/car/calendar/common/EventLocations.java create mode 100644 src/com/android/car/calendar/common/EventsLiveData.java create mode 100644 src/com/android/car/calendar/common/Navigator.java (limited to 'src/com/android/car') diff --git a/src/com/android/car/calendar/AllDayEventsItem.java b/src/com/android/car/calendar/AllDayEventsItem.java new file mode 100644 index 0000000..92c00ca --- /dev/null +++ b/src/com/android/car/calendar/AllDayEventsItem.java @@ -0,0 +1,111 @@ +/* + * 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 static com.google.common.base.Preconditions.checkNotNull; + +import android.content.res.Resources; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.ImageView; +import android.widget.LinearLayout; +import android.widget.TextView; + +import androidx.recyclerview.widget.RecyclerView; + +import java.util.List; + +class AllDayEventsItem implements CalendarItem { + + private final List mAllDayEventItems; + + AllDayEventsItem(List allDayEventItems) { + mAllDayEventItems = allDayEventItems; + } + + @Override + public CalendarItem.Type getType() { + return Type.ALL_DAY_EVENTS; + } + + @Override + public void bind(RecyclerView.ViewHolder holder) { + ((AllDayEventsViewHolder) holder).update(mAllDayEventItems); + } + + static class AllDayEventsViewHolder extends RecyclerView.ViewHolder { + + private final ImageView mExpandCollapseIcon; + private final TextView mTitleTextView; + private final Resources mResources; + private final LinearLayout mEventItemsView; + private boolean mExpanded; + + AllDayEventsViewHolder(ViewGroup parent) { + super( + LayoutInflater.from(parent.getContext()) + .inflate( + R.layout.all_day_events_item, + parent, + /* attachToRoot= */ false)); + mExpandCollapseIcon = checkNotNull(itemView.findViewById(R.id.expand_collapse_icon)); + mTitleTextView = checkNotNull(itemView.findViewById(R.id.title_text)); + mEventItemsView = checkNotNull(itemView.findViewById(R.id.events)); + + mResources = parent.getResources(); + View expandCollapseView = checkNotNull(itemView.findViewById(R.id.expand_collapse)); + expandCollapseView.setOnClickListener(this::onToggleClick); + } + + void update(List eventCalendarItems) { + mEventItemsView.removeAllViews(); + mExpanded = false; + hideEventSection(); + + int size = eventCalendarItems.size(); + mTitleTextView.setText( + mResources.getQuantityString(R.plurals.all_day_title, size, size)); + + for (EventCalendarItem eventCalendarItem : eventCalendarItems) { + EventCalendarItem.EventViewHolder holder = + new EventCalendarItem.EventViewHolder(mEventItemsView); + mEventItemsView.addView(holder.itemView); + eventCalendarItem.bind(holder); + } + } + + private void onToggleClick(View view) { + mExpanded = !mExpanded; + if (mExpanded) { + showEventSection(); + } else { + hideEventSection(); + } + } + + private void hideEventSection() { + mExpandCollapseIcon.setImageResource(R.drawable.ic_navigation_expand_more_white_24dp); + mEventItemsView.setVisibility(View.GONE); + } + + private void showEventSection() { + mExpandCollapseIcon.setImageResource(R.drawable.ic_navigation_expand_less_white_24dp); + mEventItemsView.setVisibility(View.VISIBLE); + } + } +} diff --git a/src/com/android/car/calendar/CalendarItem.java b/src/com/android/car/calendar/CalendarItem.java new file mode 100644 index 0000000..899d986 --- /dev/null +++ b/src/com/android/car/calendar/CalendarItem.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; + +import android.view.ViewGroup; + +import androidx.recyclerview.widget.RecyclerView; + +/** + * Represents an item that can be displayed in the calendar list. It will hold the data needed to + * populate the {@link androidx.recyclerview.widget.RecyclerView.ViewHolder} passed in to the {@link + * #bind(RecyclerView.ViewHolder)} method. + */ +interface CalendarItem { + + /** Returns the type of this calendar item instance. */ + Type getType(); + + /** Bind the view holder with the data represented by this item. */ + void bind(RecyclerView.ViewHolder holder); + + /** The type of the calendar item which knows how to create a view holder for */ + enum Type { + EVENT { + @Override + RecyclerView.ViewHolder createViewHolder(ViewGroup parent) { + return new EventCalendarItem.EventViewHolder(parent); + } + }, + TITLE { + @Override + RecyclerView.ViewHolder createViewHolder(ViewGroup parent) { + return new TitleCalendarItem.TitleViewHolder(parent); + } + }, + ALL_DAY_EVENTS { + @Override + RecyclerView.ViewHolder createViewHolder(ViewGroup parent) { + return new AllDayEventsItem.AllDayEventsViewHolder(parent); + } + }; + + /** Creates a view holder for this type of calendar item. */ + abstract RecyclerView.ViewHolder createViewHolder(ViewGroup parent); + } +} diff --git a/src/com/android/car/calendar/CarCalendarActivity.java b/src/com/android/car/calendar/CarCalendarActivity.java new file mode 100644 index 0000000..ca27931 --- /dev/null +++ b/src/com/android/car/calendar/CarCalendarActivity.java @@ -0,0 +1,168 @@ +/* + * 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.ContentResolver; +import android.content.pm.PackageManager; +import android.os.Bundle; +import android.os.StrictMode; +import android.util.Log; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.VisibleForTesting; +import androidx.fragment.app.FragmentActivity; +import androidx.lifecycle.ViewModel; +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.google.common.collect.HashMultimap; +import com.google.common.collect.Multimap; + +import java.time.Clock; +import java.util.Collection; +import java.util.Locale; + +/** The main Activity for the Car Calendar App. */ +public class CarCalendarActivity extends FragmentActivity { + private static final String TAG = "CarCalendarActivity"; + private static final boolean DEBUG = Log.isLoggable(TAG, Log.DEBUG); + + private final Multimap mPermissionToCallbacks = HashMultimap.create(); + + // Allows tests to replace certain dependencies. + @VisibleForTesting Dependencies mDependencies = new Dependencies( + Locale.getDefault(), Clock.systemDefaultZone(), getContentResolver()); + + @Override + protected void onCreate(@Nullable Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + maybeEnableStrictMode(); + + CarCalendarViewModel carCalendarViewModel = + new ViewModelProvider( + this, + new CarCalendarViewModelFactory( + mDependencies.mResolver, + mDependencies.mLocale, + mDependencies.mClock)) + .get(CarCalendarViewModel.class); + + CarCalendarView carCalendarView = + new CarCalendarView( + this, + carCalendarViewModel, + new Navigator(this), + new Dialer(this), + new CalendarFormatter( + this.getApplicationContext(), + mDependencies.mLocale, + mDependencies.mClock)); + + carCalendarView.show(); + } + + private void maybeEnableStrictMode() { + if (DEBUG) { + Log.i(TAG, "Enabling strict mode"); + StrictMode.setThreadPolicy( + new StrictMode.ThreadPolicy.Builder() + .detectAll() + .penaltyLog() + .penaltyDeath() + .build()); + StrictMode.setVmPolicy( + new StrictMode.VmPolicy.Builder() + .detectAll() + .penaltyLog() + .penaltyDeath() + .build()); + } + } + + /** + * Calls the given runnable only if the required permission is granted. + * + *

If the permission is already granted then the runnable is called immediately. Otherwise + * the runnable is retained and the permission is requested. If the permission is granted the + * runnable will be called otherwise it will be discarded. + */ + void runWithPermission(String permission, Runnable runnable) { + if (checkSelfPermission(permission) == PackageManager.PERMISSION_GRANTED) { + // Run immediately if we already have permission. + if (DEBUG) Log.d(TAG, "Running with " + permission); + runnable.run(); + } else { + // Keep the runnable until the permission is granted. + if (DEBUG) Log.d(TAG, "Waiting for " + permission); + mPermissionToCallbacks.put(permission, runnable); + requestPermissions(new String[] {permission}, /* requestCode= */ 0); + } + } + + @Override + public void onRequestPermissionsResult( + int requestCode, String[] permissions, int[] grantResults) { + super.onRequestPermissionsResult(requestCode, permissions, grantResults); + for (int i = 0; i < permissions.length; i++) { + String permission = permissions[i]; + int grantResult = grantResults[i]; + Collection callbacks = mPermissionToCallbacks.removeAll(permission); + if (grantResult == PackageManager.PERMISSION_GRANTED) { + Log.e(TAG, "Permission " + permission + " granted"); + callbacks.forEach(Runnable::run); + } else { + // TODO(jdp) Also allow a denied runnable. + Log.e(TAG, "Permission " + permission + " not granted"); + } + } + } + + private static class CarCalendarViewModelFactory implements ViewModelProvider.Factory { + private final ContentResolver mResolver; + private final Locale mLocale; + private final Clock mClock; + + CarCalendarViewModelFactory(ContentResolver resolver, Locale locale, Clock clock) { + mResolver = resolver; + mLocale = locale; + mClock = clock; + } + + @SuppressWarnings("unchecked") + @NonNull + @Override + public T create(@NonNull Class aClass) { + return (T) new CarCalendarViewModel(mResolver, mLocale, mClock); + } + } + + static class Dependencies { + private final Locale mLocale; + private final Clock mClock; + private final ContentResolver mResolver; + + Dependencies(Locale locale, Clock clock, ContentResolver resolver) { + mLocale = locale; + mClock = clock; + mResolver = resolver; + } + } +} diff --git a/src/com/android/car/calendar/CarCalendarView.java b/src/com/android/car/calendar/CarCalendarView.java new file mode 100644 index 0000000..07b9516 --- /dev/null +++ b/src/com/android/car/calendar/CarCalendarView.java @@ -0,0 +1,204 @@ +/* + * 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 static com.google.common.base.Verify.verify; +import static com.google.common.base.Verify.verifyNotNull; + +import android.Manifest; +import android.util.Log; +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 com.android.car.calendar.common.CalendarFormatter; +import com.android.car.calendar.common.Dialer; +import com.android.car.calendar.common.Event; +import com.android.car.calendar.common.EventsLiveData; +import com.android.car.calendar.common.Navigator; +import com.android.car.ui.recyclerview.CarUiRecyclerView; + +import com.google.common.collect.ImmutableList; + +import java.time.Duration; +import java.time.LocalDate; +import java.time.ZoneId; +import java.util.ArrayList; +import java.util.List; + +/** The main calendar app view. */ +class CarCalendarView { + private static final String TAG = "CarCalendarView"; + private static final boolean DEBUG = Log.isLoggable(TAG, Log.DEBUG); + + /** Activity referenced as concrete type to access permissions methods. */ + private final CarCalendarActivity mCarCalendarActivity; + + /** The main calendar view. */ + private final CarCalendarViewModel mCarCalendarViewModel; + + private final Navigator mNavigator; + private final Dialer mDialer; + private final CalendarFormatter mFormatter; + private final TextView mNoEventsTextView; + + /** Holds an instance of either {@link LocalDate} or {@link Event} for each item in the list. */ + private final List mRecyclerViewItems = new ArrayList<>(); + + private final RecyclerView.Adapter mAdapter = new EventRecyclerViewAdapter(); + private final Observer> 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(); + }; + + CarCalendarView( + CarCalendarActivity carCalendarActivity, + CarCalendarViewModel carCalendarViewModel, + Navigator navigator, + Dialer dialer, + CalendarFormatter formatter) { + mCarCalendarActivity = carCalendarActivity; + mCarCalendarViewModel = carCalendarViewModel; + mNavigator = navigator; + mDialer = dialer; + mFormatter = formatter; + + carCalendarActivity.setContentView(R.layout.calendar); + CarUiRecyclerView calendarRecyclerView = carCalendarActivity.findViewById(R.id.events); + mNoEventsTextView = carCalendarActivity.findViewById(R.id.no_events_text); + calendarRecyclerView.setHasFixedSize(true); + calendarRecyclerView.setAdapter(mAdapter); + } + + void show() { + // TODO(jdp) If permission is denied then show some UI to allow them to retry. + mCarCalendarActivity.runWithPermission( + Manifest.permission.READ_CALENDAR, this::showWithPermission); + } + + private void showWithPermission() { + EventsLiveData eventsLiveData = mCarCalendarViewModel.getEventsLiveData(); + eventsLiveData.observe(mCarCalendarActivity, mEventsObserver); + updateRecyclerViewItems(verifyNotNull(eventsLiveData.getValue())); + } + + /** + * 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 carCalendarEvents) { + LocalDate currentDate = null; + mRecyclerViewItems.clear(); + + if (carCalendarEvents == null) { + mNoEventsTextView.setVisibility(View.VISIBLE); + mNoEventsTextView.setText(R.string.no_calendars); + return; + } + if (carCalendarEvents.isEmpty()) { + mNoEventsTextView.setVisibility(View.VISIBLE); + mNoEventsTextView.setText(R.string.no_events); + return; + } + mNoEventsTextView.setVisibility(View.GONE); + + // Add all rows in the calendar list. + // A day might have all-day events that need to be added before regular events so we need to + // add the event rows after looking at all events for the day. + List eventItems = null; + List allDayEventItems = null; + for (Event event : carCalendarEvents) { + LocalDate date = + event.getDayStartInstant().atZone(ZoneId.systemDefault()).toLocalDate(); + + // Start a new section when the date changes. + if (!date.equals(currentDate)) { + verify( + currentDate == null || !date.isBefore(currentDate), + "Expected events to be sorted by start time"); + currentDate = date; + + // Add the events from the previous day. + if (eventItems != null) { + verify(allDayEventItems != null); + addAllEvents(allDayEventItems, eventItems); + } + + mRecyclerViewItems.add(new TitleCalendarItem(date, mFormatter)); + allDayEventItems = new ArrayList<>(); + eventItems = new ArrayList<>(); + } + + // Events that last 24 hours or longer are also shown with all day events. + if (event.isAllDay() || event.getDuration().compareTo(Duration.ofDays(1)) >= 0) { + // Only add a row when necessary because hiding it can leave padding or decorations. + allDayEventItems.add( + new EventCalendarItem( + event, mFormatter, mNavigator, mDialer, mCarCalendarActivity)); + } else { + eventItems.add( + new EventCalendarItem( + event, mFormatter, mNavigator, mDialer, mCarCalendarActivity)); + } + } + addAllEvents(allDayEventItems, eventItems); + } + + private void addAllEvents( + List allDayEventItems, List eventItems) { + if (allDayEventItems.size() > 1) { + mRecyclerViewItems.add(new AllDayEventsItem(allDayEventItems)); + } else if (allDayEventItems.size() == 1) { + mRecyclerViewItems.add(allDayEventItems.get(0)); + } + mRecyclerViewItems.addAll(eventItems); + } + + private class EventRecyclerViewAdapter extends RecyclerView.Adapter { + + @NonNull + @Override + public RecyclerView.ViewHolder onCreateViewHolder(@NonNull 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) { + mRecyclerViewItems.get(position).bind(holder); + } + + @Override + public int getItemCount() { + return mRecyclerViewItems.size(); + } + + @Override + public int getItemViewType(int position) { + return mRecyclerViewItems.get(position).getType().ordinal(); + } + } +} diff --git a/src/com/android/car/calendar/CarCalendarViewModel.java b/src/com/android/car/calendar/CarCalendarViewModel.java new file mode 100644 index 0000000..c8e80ee --- /dev/null +++ b/src/com/android/car/calendar/CarCalendarViewModel.java @@ -0,0 +1,71 @@ +/* + * 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.ContentResolver; +import android.os.HandlerThread; +import android.util.Log; + +import androidx.annotation.Nullable; +import androidx.lifecycle.ViewModel; + +import com.android.car.calendar.common.EventDescriptions; +import com.android.car.calendar.common.EventLocations; +import com.android.car.calendar.common.EventsLiveData; + +import java.time.Clock; +import java.util.Locale; + +class CarCalendarViewModel extends ViewModel { + private static final String TAG = "CarCalendarViewModel"; + private static final boolean DEBUG = Log.isLoggable(TAG, Log.DEBUG); + + private final HandlerThread mHandlerThread = new HandlerThread("CarCalendarBackground"); + private final Clock mClock; + private final ContentResolver mResolver; + private final Locale mLocale; + + @Nullable private EventsLiveData mEventsLiveData; + + CarCalendarViewModel(ContentResolver resolver, Locale locale, Clock clock) { + if (DEBUG) Log.d(TAG, "Creating view model"); + mResolver = resolver; + mHandlerThread.start(); + mLocale = locale; + mClock = clock; + } + + /** Creates an {@link EventsLiveData} lazily and always returns the same instance. */ + EventsLiveData getEventsLiveData() { + if (mEventsLiveData == null) { + mEventsLiveData = + new EventsLiveData( + mClock, + mHandlerThread.getThreadHandler(), + mResolver, + new EventDescriptions(mLocale), + new EventLocations()); + } + return mEventsLiveData; + } + + @Override + protected void onCleared() { + super.onCleared(); + mHandlerThread.quitSafely(); + } +} diff --git a/src/com/android/car/calendar/DrawableStateImageButton.java b/src/com/android/car/calendar/DrawableStateImageButton.java new file mode 100644 index 0000000..4ebd06e --- /dev/null +++ b/src/com/android/car/calendar/DrawableStateImageButton.java @@ -0,0 +1,72 @@ +/* + * 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 new file mode 100644 index 0000000..0642baa --- /dev/null +++ b/src/com/android/car/calendar/EventCalendarItem.java @@ -0,0 +1,314 @@ +/* + * 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.Manifest; +import android.annotation.ColorInt; +import android.graphics.Paint; +import android.graphics.Typeface; +import android.graphics.drawable.InsetDrawable; +import android.graphics.drawable.ShapeDrawable; +import android.graphics.drawable.shapes.OvalShape; +import android.text.SpannableString; +import android.text.style.ForegroundColorSpan; +import android.text.style.ImageSpan; +import android.text.style.StyleSpan; +import android.view.LayoutInflater; +import android.view.View.OnClickListener; +import android.view.ViewGroup; +import android.widget.TextView; +import android.widget.Toast; + +import androidx.annotation.DrawableRes; +import androidx.core.content.ContextCompat; +import androidx.recyclerview.widget.RecyclerView; + +import com.android.car.calendar.common.CalendarFormatter; +import com.android.car.calendar.common.Dialer; +import com.android.car.calendar.common.Event; +import com.android.car.calendar.common.Navigator; + +import com.google.common.base.Joiner; +import com.google.common.base.Strings; +import com.google.common.collect.Lists; + +import javax.annotation.Nullable; + +/** An item in the calendar list view that shows a single event. */ +class EventCalendarItem implements CalendarItem { + private final Event mEvent; + private final CalendarFormatter mFormatter; + private final Navigator mNavigator; + private final Dialer mDialer; + private final CarCalendarActivity mCarCalendarActivity; + + EventCalendarItem( + Event event, + CalendarFormatter formatter, + Navigator navigator, + Dialer dialer, + CarCalendarActivity carCalendarActivity) { + mEvent = event; + mFormatter = formatter; + mNavigator = navigator; + mDialer = dialer; + mCarCalendarActivity = carCalendarActivity; + } + + @Override + public Type getType() { + return Type.EVENT; + } + + @Override + public void bind(RecyclerView.ViewHolder holder) { + EventViewHolder eventViewHolder = (EventViewHolder) holder; + + EventAction primaryAction; + if (!Strings.isNullOrEmpty(mEvent.getLocation())) { + primaryAction = + new EventAction( + R.drawable.ic_navigation_gm2_24px, + mEvent.getLocation(), + (view) -> mNavigator.navigate(mEvent.getLocation())); + } else { + primaryAction = + new EventAction( + R.drawable.ic_navigation_gm2_24px, /* descriptionText */ + null, /* onClickHandler */ + null); + } + + EventAction secondaryAction; + Dialer.NumberAndAccess numberAndAccess = mEvent.getNumberAndAccess(); + if (numberAndAccess != null) { + String dialDescriptionText; + if (numberAndAccess.getAccess() != null) { + dialDescriptionText = + mCarCalendarActivity.getString( + R.string.phone_number_with_pin, + numberAndAccess.getNumber(), + numberAndAccess.getAccess()); + + } else { + dialDescriptionText = + mCarCalendarActivity.getString( + R.string.phone_number, numberAndAccess.getNumber()); + } + secondaryAction = + new EventAction( + R.drawable.ic_phone_gm2_24px, + dialDescriptionText, + (view) -> dial(numberAndAccess)); + } else { + secondaryAction = + new EventAction( + R.drawable.ic_phone_gm2_24px, + /* descriptionText */ null, + /* onClickListener= */ null); + } + + String timeRangeText; + if (!mEvent.isAllDay()) { + timeRangeText = + mFormatter.getTimeRangeText(mEvent.getStartInstant(), mEvent.getEndInstant()); + } else { + timeRangeText = mCarCalendarActivity.getString(R.string.all_day_event); + } + eventViewHolder.update( + timeRangeText, + mEvent.getTitle(), + mEvent.getCalendarDetails().getColor(), + mEvent.getCalendarDetails().getName(), + primaryAction, + secondaryAction, + mEvent.getStatus()); + } + + private void dial(Dialer.NumberAndAccess numberAndAccess) { + mCarCalendarActivity.runWithPermission( + Manifest.permission.CALL_PHONE, + () -> { + if (!mDialer.dial(numberAndAccess)) { + Toast.makeText(mCarCalendarActivity, R.string.no_dialler, Toast.LENGTH_LONG) + .show(); + } + }); + } + + private static class EventAction { + @DrawableRes private final int mIconResourceId; + @Nullable private final String mDescriptionText; + @Nullable private final OnClickListener mOnClickListener; + + private EventAction( + int iconResourceId, + @Nullable String descriptionText, + @Nullable OnClickListener onClickListener) { + this.mIconResourceId = iconResourceId; + this.mDescriptionText = descriptionText; + this.mOnClickListener = onClickListener; + } + } + + static class EventViewHolder extends RecyclerView.ViewHolder { + private static final String FIELD_SEPARATOR = ", "; + private static final Joiner JOINER = Joiner.on(FIELD_SEPARATOR).skipNulls(); + + private final TextView mTitleView; + private final TextView mDescriptionView; + private final DrawableStateImageButton mPrimaryActionButton; + private final DrawableStateImageButton mSecondaryActionButton; + private final int mCalendarIndicatorSize; + private final int mCalendarIndicatorPadding; + @ColorInt private final int mTimeTextColor; + + EventViewHolder(ViewGroup parent) { + super( + LayoutInflater.from(parent.getContext()) + .inflate(R.layout.event_item, parent, /* attachToRoot= */ false)); + mTitleView = itemView.findViewById(R.id.event_title); + mDescriptionView = itemView.findViewById(R.id.description_text); + mPrimaryActionButton = itemView.findViewById(R.id.primary_action_button); + mSecondaryActionButton = itemView.findViewById(R.id.secondary_action_button); + mCalendarIndicatorSize = + (int) parent.getResources().getDimension(R.dimen.car_calendar_indicator_width); + mCalendarIndicatorPadding = + (int) parent.getResources().getDimension(R.dimen.car_ui_padding_1); + mTimeTextColor = + ContextCompat.getColor(parent.getContext(), R.color.car_ui_text_color_primary); + } + + void update( + String timeRangeText, + String title, + @ColorInt int calendarColor, + String calendarName, + EventAction primaryAction, + EventAction secondaryAction, + Event.Status status) { + + mTitleView.setText(title); + + String detailText = null; + if (primaryAction.mDescriptionText != null) { + detailText = primaryAction.mDescriptionText; + } else if (secondaryAction.mDescriptionText != null) { + detailText = secondaryAction.mDescriptionText; + } + SpannableString descriptionSpannable = + createDescriptionSpannable( + calendarColor, calendarName, timeRangeText, detailText); + + mDescriptionView.setText(descriptionSpannable); + + // Strike-through all text fields when the event was declined. + setTextFlags( + Paint.STRIKE_THRU_TEXT_FLAG, + /* add= */ status.equals(Event.Status.DECLINED), + mTitleView, + mDescriptionView); + + mPrimaryActionButton.setImageResource(primaryAction.mIconResourceId); + if (primaryAction.mOnClickListener != null) { + mPrimaryActionButton.setEnabled(true); + mPrimaryActionButton.setContentDescription(primaryAction.mDescriptionText); + mPrimaryActionButton.setOnClickListener(primaryAction.mOnClickListener); + } else { + mPrimaryActionButton.setEnabled(false); + mPrimaryActionButton.setContentDescription(null); + mPrimaryActionButton.setOnClickListener(null); + } + + mSecondaryActionButton.setImageResource(secondaryAction.mIconResourceId); + if (secondaryAction.mOnClickListener != null) { + mSecondaryActionButton.setEnabled(true); + mSecondaryActionButton.setContentDescription(secondaryAction.mDescriptionText); + mSecondaryActionButton.setOnClickListener(secondaryAction.mOnClickListener); + } else { + mSecondaryActionButton.setEnabled(false); + mSecondaryActionButton.setContentDescription(null); + mSecondaryActionButton.setOnClickListener(null); + } + } + + private SpannableString createDescriptionSpannable( + @ColorInt int calendarColor, + String calendarName, + String timeRangeText, + @Nullable String detailText) { + ShapeDrawable calendarIndicatorDrawable = new ShapeDrawable(new OvalShape()); + calendarIndicatorDrawable.getPaint().setColor(calendarColor); + + calendarIndicatorDrawable.setBounds( + /* left= */ 0, + /* top= */ 0, + /* right= */ mCalendarIndicatorSize, + /* bottom= */ mCalendarIndicatorSize); + + // Add padding to the right of the image to separate it from the text. + InsetDrawable insetDrawable = + new InsetDrawable( + calendarIndicatorDrawable, + /* insetLeft= */ 0, + /* insetTop= */ 0, + /* insetRight= */ mCalendarIndicatorPadding, + /* insetBottom= */ 0); + + insetDrawable.setBounds( + /* left= */ 0, + /* top= */ 0, + /* right= */ mCalendarIndicatorSize + mCalendarIndicatorPadding, + /* bottom= */ mCalendarIndicatorSize); + + String descriptionText = + JOINER.join(Lists.newArrayList(calendarName, timeRangeText, detailText)); + SpannableString descriptionSpannable = new SpannableString(descriptionText); + ImageSpan calendarIndicatorSpan = + new ImageSpan(insetDrawable, ImageSpan.ALIGN_BASELINE); + int calendarNameEnd = calendarName.length() + FIELD_SEPARATOR.length(); + descriptionSpannable.setSpan( + calendarIndicatorSpan, /* start= */ 0, calendarNameEnd, /* flags= */ 0); + int timeEnd = calendarNameEnd + timeRangeText.length(); + descriptionSpannable.setSpan( + new StyleSpan(Typeface.BOLD), calendarNameEnd, timeEnd, /* flags= */ 0); + descriptionSpannable.setSpan( + new ForegroundColorSpan(mTimeTextColor), + calendarNameEnd, + timeEnd, + /* flags= */ 0); + return descriptionSpannable; + } + + /** + * Set paint flags on the given text views. + * + * @param flags The combined {@link Paint} flags to set or unset. + * @param set Set the flags if true, otherwise unset. + * @param views The views to apply the flags to. + */ + private void setTextFlags(int flags, boolean set, TextView... views) { + for (TextView view : views) { + if (set) { + view.setPaintFlags(view.getPaintFlags() | flags); + } else { + view.setPaintFlags(view.getPaintFlags() & ~flags); + } + } + } + } +} diff --git a/src/com/android/car/calendar/TitleCalendarItem.java b/src/com/android/car/calendar/TitleCalendarItem.java new file mode 100644 index 0000000..cc278b3 --- /dev/null +++ b/src/com/android/car/calendar/TitleCalendarItem.java @@ -0,0 +1,65 @@ +/* + * 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.view.LayoutInflater; +import android.view.ViewGroup; +import android.widget.TextView; + +import androidx.recyclerview.widget.RecyclerView; + +import com.android.car.calendar.common.CalendarFormatter; + +import java.time.LocalDate; + +/** An item in the calendar list view that shows a title row for the following list of events. */ +class TitleCalendarItem implements CalendarItem { + + private final LocalDate mDate; + private final CalendarFormatter mFormatter; + + TitleCalendarItem(LocalDate date, CalendarFormatter formatter) { + mDate = date; + mFormatter = formatter; + } + + @Override + public Type getType() { + return Type.TITLE; + } + + @Override + public void bind(RecyclerView.ViewHolder holder) { + TitleViewHolder titleViewHolder = (TitleViewHolder) holder; + titleViewHolder.update(mFormatter.getDateText(mDate)); + } + + static class TitleViewHolder extends RecyclerView.ViewHolder { + private final TextView mDateTextView; + + TitleViewHolder(ViewGroup parent) { + super( + LayoutInflater.from(parent.getContext()) + .inflate(R.layout.title_item, parent, /* attachToRoot= */ false)); + mDateTextView = itemView.findViewById(R.id.date_text); + } + + void update(String dateText) { + mDateTextView.setText(dateText); + } + } +} diff --git a/src/com/android/car/calendar/common/CalendarFormatter.java b/src/com/android/car/calendar/common/CalendarFormatter.java new file mode 100644 index 0000000..175bfc1 --- /dev/null +++ b/src/com/android/car/calendar/common/CalendarFormatter.java @@ -0,0 +1,115 @@ +/* + * 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 android.text.format.DateUtils.FORMAT_ABBREV_ALL; +import static android.text.format.DateUtils.FORMAT_NO_YEAR; +import static android.text.format.DateUtils.FORMAT_SHOW_TIME; + +import android.content.Context; +import android.icu.text.DisplayContext; +import android.icu.text.RelativeDateTimeFormatter; +import android.icu.util.ULocale; +import android.text.format.DateUtils; + +import java.text.DateFormat; +import java.text.SimpleDateFormat; +import java.time.Clock; +import java.time.Instant; +import java.time.LocalDate; +import java.time.ZonedDateTime; +import java.util.Date; +import java.util.Formatter; +import java.util.Locale; +import java.util.TimeZone; + +/** App specific text formatting utility. */ +public class CalendarFormatter { + private static final String TAG = "CarCalendarFormatter"; + private static final String SPACED_BULLET = " \u2022 "; + private final Context mContext; + private final Locale mLocale; + private final Clock mClock; + private final DateFormat mDateFormat; + + public CalendarFormatter(Context context, Locale locale, Clock clock) { + mContext = context; + mLocale = locale; + mClock = clock; + + String pattern = + android.text.format.DateFormat.getBestDateTimePattern(mLocale, "EEE, d MMM"); + mDateFormat = new SimpleDateFormat(pattern, mLocale); + mDateFormat.setTimeZone(TimeZone.getTimeZone(mClock.getZone())); + } + + /** Formats the given date to text. */ + public String getDateText(LocalDate localDate) { + RelativeDateTimeFormatter formatter = + RelativeDateTimeFormatter.getInstance( + ULocale.forLocale(mLocale), + null, + RelativeDateTimeFormatter.Style.LONG, + DisplayContext.CAPITALIZATION_FOR_BEGINNING_OF_SENTENCE); + + LocalDate today = LocalDate.now(mClock); + LocalDate tomorrow = today.plusDays(1); + LocalDate dayAfter = tomorrow.plusDays(1); + + String relativeDay = null; + if (localDate.equals(today)) { + relativeDay = + formatter.format( + RelativeDateTimeFormatter.Direction.THIS, + RelativeDateTimeFormatter.AbsoluteUnit.DAY); + } else if (localDate.equals(tomorrow)) { + relativeDay = + formatter.format( + RelativeDateTimeFormatter.Direction.NEXT, + RelativeDateTimeFormatter.AbsoluteUnit.DAY); + } else if (localDate.equals(dayAfter)) { + relativeDay = + formatter.format( + RelativeDateTimeFormatter.Direction.NEXT_2, + RelativeDateTimeFormatter.AbsoluteUnit.DAY); + } + + StringBuilder result = new StringBuilder(); + if (relativeDay != null) { + result.append(relativeDay); + result.append(SPACED_BULLET); + } + + ZonedDateTime zonedDateTime = localDate.atStartOfDay(mClock.getZone()); + Date date = new Date(zonedDateTime.toInstant().toEpochMilli()); + result.append(mDateFormat.format(date)); + return result.toString(); + } + + /** Formats the given time to text. */ + public String getTimeRangeText(Instant start, Instant end) { + Formatter formatter = new Formatter(new StringBuilder(50), mLocale); + return DateUtils.formatDateRange( + mContext, + formatter, + start.toEpochMilli(), + end.toEpochMilli(), + FORMAT_SHOW_TIME | FORMAT_NO_YEAR | FORMAT_ABBREV_ALL, + mClock.getZone().getId()) + .toString(); + } +} diff --git a/src/com/android/car/calendar/common/Dialer.java b/src/com/android/car/calendar/common/Dialer.java new file mode 100644 index 0000000..889a3c8 --- /dev/null +++ b/src/com/android/car/calendar/common/Dialer.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 android.content.Context; +import android.content.Intent; +import android.content.pm.PackageManager; +import android.net.Uri; +import android.telephony.PhoneNumberUtils; +import android.util.Log; + +import com.google.common.base.MoreObjects; +import com.google.common.base.Strings; + +import javax.annotation.Nullable; + +/** Calls the default dialer with an optional access code. */ +public class Dialer { + + private static final String TAG = "CarCalendarDialer"; + private static final boolean DEBUG = Log.isLoggable(TAG, Log.DEBUG); + + private final Context mContext; + + public Dialer(Context context) { + mContext = context; + } + + /** Calls a telephone using a phone number and access number. */ + public boolean dial(NumberAndAccess numberAndAccess) { + StringBuilder sb = new StringBuilder(numberAndAccess.getNumber()); + String access = numberAndAccess.getAccess(); + if (!Strings.isNullOrEmpty(access)) { + // Wait for the number to dial if required. + char first = access.charAt(0); + if (first != PhoneNumberUtils.PAUSE && first != PhoneNumberUtils.WAIT) { + // Insert a wait so the number finishes dialing before using the access code. + access = PhoneNumberUtils.WAIT + access; + } + sb.append(access); + } + Uri dialUri = Uri.fromParts("tel", sb.toString(), /* fragment= */ null); + PackageManager packageManager = mContext.getPackageManager(); + boolean useActionCall = packageManager.hasSystemFeature(PackageManager.FEATURE_TELEPHONY); + Intent intent = new Intent(useActionCall ? Intent.ACTION_CALL : Intent.ACTION_DIAL); + intent.setData(dialUri); + if (intent.resolveActivity(packageManager) == null) { + Log.i(TAG, "No dialler app found"); + return false; + } + if (DEBUG) Log.d(TAG, "Starting dialler activity"); + mContext.startActivity(intent); + return true; + } + + /** An immutable value representing the details required to enter a conference call. */ + public static class NumberAndAccess { + private final String mNumber; + + @Nullable private final String mAccess; + + NumberAndAccess(String number, @Nullable String access) { + this.mNumber = number; + this.mAccess = access; + } + + public String getNumber() { + return mNumber; + } + + @Nullable + public String getAccess() { + return mAccess; + } + + @Override + public String toString() { + return MoreObjects.toStringHelper(this) + .add("mNumber", mNumber) + .add("mAccess", mAccess) + .toString(); + } + } +} diff --git a/src/com/android/car/calendar/common/Event.java b/src/com/android/car/calendar/common/Event.java new file mode 100644 index 0000000..4395d33 --- /dev/null +++ b/src/com/android/car/calendar/common/Event.java @@ -0,0 +1,135 @@ +/* + * 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 com.android.car.calendar.common.Dialer.NumberAndAccess; + +import java.time.Duration; +import java.time.Instant; + +/** + * An immutable value representing a calendar event. Should contain only details that are relevant + * to the Car Calendar. + */ +public final class Event { + + /** The status of the current user for this event. */ + public enum Status { + ACCEPTED, + DECLINED, + NONE, + } + + /** + * The details required for display of the calendar indicator. + */ + public static class CalendarDetails { + private final String mName; + private final int mColor; + + CalendarDetails(String name, int color) { + mName = name; + mColor = color; + } + + public int getColor() { + return mColor; + } + + public String getName() { + return mName; + } + } + + private final boolean mAllDay; + private final Instant mStartInstant; + private final Instant mDayStartInstant; + private final Instant mEndInstant; + private final Instant mDayEndInstant; + private final String mTitle; + private final Status mStatus; + private final String mLocation; + private final NumberAndAccess mNumberAndAccess; + private final CalendarDetails mCalendarDetails; + + Event( + boolean allDay, + Instant startInstant, + Instant dayStartInstant, + Instant endInstant, + Instant dayEndInstant, + String title, + Status status, + String location, + NumberAndAccess numberAndAccess, + CalendarDetails calendarDetails) { + mAllDay = allDay; + mStartInstant = startInstant; + mDayStartInstant = dayStartInstant; + mEndInstant = endInstant; + mDayEndInstant = dayEndInstant; + mTitle = title; + mStatus = status; + mLocation = location; + mNumberAndAccess = numberAndAccess; + mCalendarDetails = calendarDetails; + } + + public Instant getStartInstant() { + return mStartInstant; + } + + public Instant getDayStartInstant() { + return mDayStartInstant; + } + + public Instant getDayEndInstant() { + return mDayEndInstant; + } + + public Instant getEndInstant() { + return mEndInstant; + } + + public String getTitle() { + return mTitle; + } + + public NumberAndAccess getNumberAndAccess() { + return mNumberAndAccess; + } + + public CalendarDetails getCalendarDetails() { + return mCalendarDetails; + } + + public String getLocation() { + return mLocation; + } + + public Status getStatus() { + return mStatus; + } + + public boolean isAllDay() { + return mAllDay; + } + + public Duration getDuration() { + return Duration.between(getStartInstant(), getEndInstant()); + } +} diff --git a/src/com/android/car/calendar/common/EventDescriptions.java b/src/com/android/car/calendar/common/EventDescriptions.java new file mode 100644 index 0000000..e6aad5b --- /dev/null +++ b/src/com/android/car/calendar/common/EventDescriptions.java @@ -0,0 +1,119 @@ +/* + * 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.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 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.collect.ImmutableList; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import javax.annotation.Nullable; + +/** Utilities to manipulate the description of a calendar event which may contain meta-data. */ +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 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,}))?", + Pattern.CASE_INSENSITIVE); + + // Matches numbers in the encoded format "". + private static final Pattern TEL_PIN_PATTERN = + Pattern.compile(""); + + 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 final Locale mLocale; + + public EventDescriptions(Locale locale) { + mLocale = locale; + } + + /** Find conference call data embedded in the description. */ + public List extractNumberAndPins(String descriptionText) { + String decoded = Uri.decode(descriptionText); + + Map results = new LinkedHashMap<>(); + addMatchedNumbers(decoded, results, PHONE_PIN_PATTERN); + addMatchedNumbers(decoded, results, TEL_PIN_PATTERN); + return ImmutableList.copyOf(results.values()); + } + + private void addMatchedNumbers( + String decoded, Map results, Pattern phonePinPattern) { + Matcher phoneFormatMatcher = phonePinPattern.matcher(decoded); + while (phoneFormatMatcher.find()) { + NumberAndAccess numberAndAccess = validNumberAndAccess(phoneFormatMatcher); + if (numberAndAccess != null) { + results.put(numberAndAccess.getNumber(), numberAndAccess); + } + } + } + + @Nullable + 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. + } + 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; + } +} diff --git a/src/com/android/car/calendar/common/EventLocations.java b/src/com/android/car/calendar/common/EventLocations.java new file mode 100644 index 0000000..b3382f7 --- /dev/null +++ b/src/com/android/car/calendar/common/EventLocations.java @@ -0,0 +1,30 @@ +/* + * 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 java.util.regex.Pattern; + +/** Utilities operating on the event location field. */ +public class EventLocations { + private static final Pattern ROOM_LOCATION_PATTERN = + Pattern.compile("^[A-Z]{2,4}(?:-[0-9A-Z]{1,5}){2,}"); + + /** Returns true if the location is valid for navigation. */ + public boolean isValidLocation(String locationText) { + return !ROOM_LOCATION_PATTERN.matcher(locationText).find(); + } +} diff --git a/src/com/android/car/calendar/common/EventsLiveData.java b/src/com/android/car/calendar/common/EventsLiveData.java new file mode 100644 index 0000000..12c91e7 --- /dev/null +++ b/src/com/android/car/calendar/common/EventsLiveData.java @@ -0,0 +1,305 @@ +/* + * 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.base.Preconditions.checkState; + +import static java.time.temporal.ChronoUnit.DAYS; +import static java.time.temporal.ChronoUnit.MINUTES; + +import android.content.ContentResolver; +import android.content.ContentUris; +import android.database.ContentObserver; +import android.database.Cursor; +import android.net.Uri; +import android.os.Handler; +import android.provider.CalendarContract; +import android.provider.CalendarContract.Instances; +import android.util.Log; + +import androidx.lifecycle.LiveData; + +import com.google.common.collect.ImmutableList; +import com.google.common.collect.Iterables; + +import java.time.Clock; +import java.time.Instant; +import java.time.ZoneId; +import java.time.ZonedDateTime; +import java.time.temporal.ChronoUnit; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; + +import javax.annotation.Nullable; + +/** + * An observable source of calendar events coming from the Calendar + * Provider. + * + *

While in the active state the content provider is observed for changes. + */ +public class EventsLiveData extends LiveData> { + + private static final String TAG = "CarCalendarEventsLiveData"; + private static final boolean DEBUG = Log.isLoggable(TAG, Log.DEBUG); + + // Sort events by start date and title. + private static final Comparator EVENT_COMPARATOR = + Comparator.comparing(Event::getDayStartInstant).thenComparing(Event::getTitle); + + private final Clock mClock; + private final Handler mBackgroundHandler; + private final ContentResolver mContentResolver; + private final EventDescriptions mEventDescriptions; + private final EventLocations mLocations; + + /** The event instances cursor is a field to allow observers to be managed. */ + @Nullable private Cursor mEventsCursor; + + @Nullable private ContentObserver mEventInstancesObserver; + + public EventsLiveData( + Clock clock, + Handler backgroundHandler, + ContentResolver contentResolver, + EventDescriptions eventDescriptions, + EventLocations locations) { + super(ImmutableList.of()); + mClock = clock; + mBackgroundHandler = backgroundHandler; + mContentResolver = contentResolver; + mEventDescriptions = eventDescriptions; + mLocations = locations; + } + + /** Refreshes the event instances and sets the new value which notifies observers. */ + private void update() { + postValue(getEventsUntilTomorrow()); + } + + /** Queries the content provider for event instances. */ + @Nullable + private ImmutableList getEventsUntilTomorrow() { + // Check we are running on our background thread. + checkState(mBackgroundHandler.getLooper().isCurrentThread()); + + if (mEventsCursor != null) { + tearDownCursor(); + } + + ZonedDateTime now = ZonedDateTime.now(mClock); + + // Find all events in the current day to include any all-day events. + ZonedDateTime startDateTime = now.truncatedTo(DAYS); + ZonedDateTime endDateTime = startDateTime.plusDays(2).truncatedTo(ChronoUnit.DAYS); + + // Always create the cursor so we can observe it for changes to events. + mEventsCursor = createEventsCursor(startDateTime, endDateTime); + + // If there are no calendars we return null + if (!hasCalendars()) { + return null; + } + + List events = new ArrayList<>(); + while (mEventsCursor.moveToNext()) { + List eventsForRow = createEventsForRow(mEventsCursor, mEventDescriptions); + for (Event event : eventsForRow) { + // Filter out any events that do not overlap the time window. + if (event.getDayEndInstant().isBefore(now.toInstant()) + || !event.getDayStartInstant().isBefore(endDateTime.toInstant())) { + continue; + } + events.add(event); + } + } + events.sort(EVENT_COMPARATOR); + return ImmutableList.copyOf(events); + } + + private boolean hasCalendars() { + try (Cursor cursor = + mContentResolver.query(CalendarContract.Calendars.CONTENT_URI, null, null, null)) { + return cursor == null || cursor.getCount() > 0; + } + } + + /** Creates a new {@link Cursor} over event instances with an updated time range. */ + private Cursor createEventsCursor(ZonedDateTime startDateTime, ZonedDateTime endDateTime) { + Uri.Builder eventInstanceUriBuilder = CalendarContract.Instances.CONTENT_URI.buildUpon(); + if (DEBUG) Log.d(TAG, "Reading from " + startDateTime + " to " + endDateTime); + + ContentUris.appendId(eventInstanceUriBuilder, startDateTime.toInstant().toEpochMilli()); + ContentUris.appendId(eventInstanceUriBuilder, endDateTime.toInstant().toEpochMilli()); + Uri eventInstanceUri = eventInstanceUriBuilder.build(); + Cursor cursor = + mContentResolver.query( + eventInstanceUri, + /* projection= */ null, + /* selection= */ null, + /* selectionArgs= */ null, + Instances.BEGIN); + + // Set an observer on the Cursor, not the ContentResolver so it can be mocked for tests. + mEventInstancesObserver = + new ContentObserver(mBackgroundHandler) { + @Override + public boolean deliverSelfNotifications() { + return true; + } + + @Override + public void onChange(boolean selfChange) { + if (DEBUG) Log.d(TAG, "Events changed"); + update(); + } + }; + cursor.setNotificationUri(mContentResolver, eventInstanceUri); + cursor.registerContentObserver(mEventInstancesObserver); + + return cursor; + } + + /** Can return multiple events for a single cursor row when an event spans multiple days. */ + private List createEventsForRow( + Cursor eventInstancesCursor, EventDescriptions eventDescriptions) { + String titleText = text(eventInstancesCursor, Instances.TITLE); + + boolean allDay = integer(eventInstancesCursor, CalendarContract.Events.ALL_DAY) == 1; + String descriptionText = text(eventInstancesCursor, Instances.DESCRIPTION); + + long startTimeMs = integer(eventInstancesCursor, Instances.BEGIN); + long endTimeMs = integer(eventInstancesCursor, Instances.END); + + Instant startInstant = Instant.ofEpochMilli(startTimeMs); + Instant endInstant = Instant.ofEpochMilli(endTimeMs); + + // If an event is all-day then the times are stored in UTC and must be adjusted. + if (allDay) { + startInstant = utcToDefaultTimeZone(startInstant); + endInstant = utcToDefaultTimeZone(endInstant); + } + + String locationText = text(eventInstancesCursor, Instances.EVENT_LOCATION); + if (!mLocations.isValidLocation(locationText)) { + locationText = null; + } + + List numberAndAccesses = + eventDescriptions.extractNumberAndPins(descriptionText); + Dialer.NumberAndAccess numberAndAccess = Iterables.getFirst(numberAndAccesses, null); + long calendarColor = integer(eventInstancesCursor, Instances.CALENDAR_COLOR); + String calendarName = text(eventInstancesCursor, Instances.CALENDAR_DISPLAY_NAME); + int selfAttendeeStatus = + (int) integer(eventInstancesCursor, Instances.SELF_ATTENDEE_STATUS); + + Event.Status status; + switch (selfAttendeeStatus) { + case CalendarContract.Attendees.ATTENDEE_STATUS_ACCEPTED: + status = Event.Status.ACCEPTED; + break; + case CalendarContract.Attendees.ATTENDEE_STATUS_DECLINED: + status = Event.Status.DECLINED; + break; + default: + status = Event.Status.NONE; + } + + // Add an Event for each day of events that span multiple days. + List events = new ArrayList<>(); + Instant dayStartInstant = + startInstant.atZone(mClock.getZone()).truncatedTo(DAYS).toInstant(); + Instant dayEndInstant; + do { + dayEndInstant = dayStartInstant.plus(1, DAYS); + events.add( + new Event( + allDay, + startInstant, + dayStartInstant.isAfter(startInstant) ? dayStartInstant : startInstant, + endInstant, + dayEndInstant.isBefore(endInstant) ? dayEndInstant : endInstant, + titleText, + status, + locationText, + numberAndAccess, + new Event.CalendarDetails(calendarName, (int) calendarColor))); + dayStartInstant = dayEndInstant; + } while (dayStartInstant.isBefore(endInstant)); + return events; + } + + private Instant utcToDefaultTimeZone(Instant instant) { + return instant.atZone(ZoneId.of("UTC")).withZoneSameLocal(mClock.getZone()).toInstant(); + } + + @Override + protected void onActive() { + super.onActive(); + if (DEBUG) Log.d(TAG, "Live data active"); + mBackgroundHandler.post(this::updateAndScheduleNext); + } + + @Override + protected void onInactive() { + super.onInactive(); + if (DEBUG) Log.d(TAG, "Live data inactive"); + mBackgroundHandler.post(this::cancelScheduledUpdate); + mBackgroundHandler.post(this::tearDownCursor); + } + + /** Calls {@link #update()} every minute to keep the displayed time range correct. */ + private void updateAndScheduleNext() { + if (DEBUG) Log.d(TAG, "Update and schedule"); + if (hasActiveObservers()) { + update(); + ZonedDateTime now = ZonedDateTime.now(mClock); + ZonedDateTime truncatedNowTime = now.truncatedTo(MINUTES); + ZonedDateTime updateTime = truncatedNowTime.plus(1, MINUTES); + long delayMs = updateTime.toInstant().toEpochMilli() - now.toInstant().toEpochMilli(); + if (DEBUG) Log.d(TAG, "Scheduling in " + delayMs); + mBackgroundHandler.postDelayed(this::updateAndScheduleNext, this, delayMs); + } + } + + private void cancelScheduledUpdate() { + mBackgroundHandler.removeCallbacksAndMessages(this); + } + + private void tearDownCursor() { + if (mEventsCursor != null) { + if (DEBUG) Log.d(TAG, "Closing cursor and unregistering observer"); + mEventsCursor.unregisterContentObserver(mEventInstancesObserver); + mEventsCursor.close(); + mEventsCursor = null; + } else { + // Should not happen as the cursor should have been created first on the same handler. + Log.w(TAG, "Expected cursor"); + } + } + + private static String text(Cursor cursor, String columnName) { + return cursor.getString(cursor.getColumnIndex(columnName)); + } + + /** An integer for the content provider is actually a Java long. */ + private static long integer(Cursor cursor, String columnName) { + return cursor.getLong(cursor.getColumnIndex(columnName)); + } +} diff --git a/src/com/android/car/calendar/common/Navigator.java b/src/com/android/car/calendar/common/Navigator.java new file mode 100644 index 0000000..7d8064d --- /dev/null +++ b/src/com/android/car/calendar/common/Navigator.java @@ -0,0 +1,56 @@ +/* + * 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 android.app.ActivityOptions; +import android.content.Context; +import android.content.Intent; +import android.net.Uri; +import android.util.Log; +import android.view.Display; +import android.widget.Toast; + +/** Launches a navigation activity. */ +public class Navigator { + private static final String TAG = "CarCalendarNavigator"; + private static final boolean DEBUG = Log.isLoggable(TAG, Log.DEBUG); + + private final Context mContext; + + public Navigator(Context context) { + this.mContext = context; + } + + /** Launches a navigation activity to the given address or place name. */ + public void navigate(String locationText) { + Uri navigateUri = Uri.parse("google.navigation:q=" + locationText); + Intent intent = new Intent(Intent.ACTION_VIEW, navigateUri); + if (intent.resolveActivity(mContext.getPackageManager()) != null) { + if (DEBUG) Log.d(TAG, "Starting navigation"); + + // Workaround to bring GMM to the front. CarLauncher contains an ActivityView that + // opens GMM in a virtual display which causes it not to move to the front. + // This workaround is not required for other launchers. + // TODO(b/153046584): Remove workaround for GMM not moving to front + ActivityOptions activityOptions = + ActivityOptions.makeBasic().setLaunchDisplayId(Display.DEFAULT_DISPLAY); + mContext.startActivity(intent, activityOptions.toBundle()); + } else { + Toast.makeText(mContext, "Navigation app not found", Toast.LENGTH_LONG).show(); + } + } +} -- cgit v1.2.3 From bf5f769cb28804b3a400bcc616a0f319c0ab5935 Mon Sep 17 00:00:00 2001 From: John Patterson Date: Wed, 8 Jul 2020 14:49:23 +0200 Subject: Fix problem with activity dependencies. Test: installed and ran app on Hawk. Change-Id: I51eec75db7e5d4a1b0d2717dbaaee261fe029de3 --- src/com/android/car/calendar/CarCalendarActivity.java | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) (limited to 'src/com/android/car') diff --git a/src/com/android/car/calendar/CarCalendarActivity.java b/src/com/android/car/calendar/CarCalendarActivity.java index ca27931..97e2031 100644 --- a/src/com/android/car/calendar/CarCalendarActivity.java +++ b/src/com/android/car/calendar/CarCalendarActivity.java @@ -48,14 +48,19 @@ public class CarCalendarActivity extends FragmentActivity { private final Multimap mPermissionToCallbacks = HashMultimap.create(); // Allows tests to replace certain dependencies. - @VisibleForTesting Dependencies mDependencies = new Dependencies( - Locale.getDefault(), Clock.systemDefaultZone(), getContentResolver()); + @VisibleForTesting Dependencies mDependencies; @Override protected void onCreate(@Nullable Bundle savedInstanceState) { super.onCreate(savedInstanceState); maybeEnableStrictMode(); + // Tests can set fake dependencies before onCreate. + if (mDependencies == null) { + mDependencies = new Dependencies( + Locale.getDefault(), Clock.systemDefaultZone(), getContentResolver()); + } + CarCalendarViewModel carCalendarViewModel = new ViewModelProvider( this, -- cgit v1.2.3 From 1de3c508609b7e725b53af825f2c96b1eaa47d80 Mon Sep 17 00:00:00 2001 From: John Patterson Date: Thu, 27 Aug 2020 08:57:24 +0200 Subject: Debounce calendar changes to avoid excessive view refreshes. Fixes: 166410455 Test: atest CarCalendarUnitTests CarCalendarUiTests Change-Id: I504c624be90d85b8c61ac9461bbb73b62014b9f8 --- src/com/android/car/calendar/common/Dialer.java | 15 ++++++ src/com/android/car/calendar/common/Event.java | 62 +++++++++++++++++++--- .../car/calendar/common/EventsLiveData.java | 25 +++++++-- 3 files changed, 90 insertions(+), 12 deletions(-) (limited to 'src/com/android/car') 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/EventsLiveData.java b/src/com/android/car/calendar/common/EventsLiveData.java index 12c91e7..f2df3fe 100644 --- a/src/com/android/car/calendar/common/EventsLiveData.java +++ b/src/com/android/car/calendar/common/EventsLiveData.java @@ -44,6 +44,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; @@ -59,6 +60,9 @@ public class EventsLiveData extends LiveData> { 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_COMPARATOR = Comparator.comparing(Event::getDayStartInstant).thenComparing(Event::getTitle); @@ -68,6 +72,7 @@ public class EventsLiveData extends LiveData> { private final ContentResolver mContentResolver; private final EventDescriptions mEventDescriptions; private final EventLocations mLocations; + private final Runnable mUpdateRunnable = this::updateIfChanged; /** The event instances cursor is a field to allow observers to be managed. */ @Nullable private Cursor mEventsCursor; @@ -89,8 +94,12 @@ public class EventsLiveData extends LiveData> { } /** Refreshes the event instances and sets the new value which notifies observers. */ - private void update() { - postValue(getEventsUntilTomorrow()); + private void updateIfChanged() { + ImmutableList latest = getEventsUntilTomorrow(); + ImmutableList current = getValue(); + if (!Objects.equals(latest, current)) { + postValue(latest); + } } /** Queries the content provider for event instances. */ @@ -167,7 +176,7 @@ public class EventsLiveData extends LiveData> { @Override public void onChange(boolean selfChange) { if (DEBUG) Log.d(TAG, "Events changed"); - update(); + updateWithDelay(); } }; cursor.setNotificationUri(mContentResolver, eventInstanceUri); @@ -176,6 +185,12 @@ public class EventsLiveData extends LiveData> { return cursor; } + private void updateWithDelay() { + // Do not update the events until there have been no changes for a given duration. + mBackgroundHandler.removeCallbacks(mUpdateRunnable); + mBackgroundHandler.postDelayed(mUpdateRunnable, UPDATE_DELAY_MILLIS); + } + /** Can return multiple events for a single cursor row when an event spans multiple days. */ private List createEventsForRow( Cursor eventInstancesCursor, EventDescriptions eventDescriptions) { @@ -264,11 +279,11 @@ public class EventsLiveData extends LiveData> { mBackgroundHandler.post(this::tearDownCursor); } - /** 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); -- cgit v1.2.3 From 9c08312784ae07634761ea3be402d6478a57e385 Mon Sep 17 00:00:00 2001 From: John Patterson Date: Fri, 28 Aug 2020 13:51:40 +0200 Subject: Do not set an initial value for events. This avoids the empty events being used to show "no events" message. Fixes: 153622413 Test: atest CarCalendarUnitTests CarCalendarUiTests Change-Id: Ib02ba2d8682bd92a560b73b0db4702ec4ac45e73 --- src/com/android/car/calendar/CarCalendarView.java | 39 ++++++++++------------ .../car/calendar/common/EventsLiveData.java | 22 +++++++++--- 2 files changed, 35 insertions(+), 26 deletions(-) (limited to 'src/com/android/car') 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 mRecyclerViewItems = new ArrayList<>(); - private final RecyclerView.Adapter mAdapter = new EventRecyclerViewAdapter(); - private final Observer> 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 mAdapter = new EventRecyclerViewAdapter(); + private final Observer> 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 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 carCalendarEvents) { + private void updateRecyclerViewItems(@Nullable ImmutableList 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 eventItems = null; List 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 { @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/common/EventsLiveData.java b/src/com/android/car/calendar/common/EventsLiveData.java index f2df3fe..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; @@ -54,6 +56,8 @@ import javax.annotation.Nullable; * Provider. * *

While in the active state the content provider is observed for changes. + * + *

When the value given to the observer is null it signals that there are no calendars. */ public class EventsLiveData extends LiveData> { @@ -72,20 +76,22 @@ public class EventsLiveData extends LiveData> { private final ContentResolver mContentResolver; private final EventDescriptions mEventDescriptions; private final EventLocations mLocations; - private final Runnable mUpdateRunnable = this::updateIfChanged; + 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; @@ -95,10 +101,14 @@ public class EventsLiveData extends LiveData> { /** Refreshes the event instances and sets the new value which notifies observers. */ private void updateIfChanged() { + Log.d(TAG, "Update if changed"); ImmutableList latest = getEventsUntilTomorrow(); ImmutableList current = getValue(); - if (!Objects.equals(latest, current)) { + + // Always post the first value even if it is null. + if (!mValueUpdated || !Objects.equals(latest, current)) { postValue(latest); + mValueUpdated = true; } } @@ -187,8 +197,9 @@ public class EventsLiveData extends LiveData> { private void updateWithDelay() { // Do not update the events until there have been no changes for a given duration. - mBackgroundHandler.removeCallbacks(mUpdateRunnable); - mBackgroundHandler.postDelayed(mUpdateRunnable, UPDATE_DELAY_MILLIS); + 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. */ @@ -277,6 +288,7 @@ public class EventsLiveData extends LiveData> { if (DEBUG) Log.d(TAG, "Live data inactive"); mBackgroundHandler.post(this::cancelScheduledUpdate); mBackgroundHandler.post(this::tearDownCursor); + mValueUpdated = false; } /** Calls {@link #updateIfChanged()} every minute to keep the displayed time range correct. */ -- cgit v1.2.3 From 5fe2bce08cb75583d4636aa633a61241b66aa877 Mon Sep 17 00:00:00 2001 From: Cole Faust Date: Tue, 8 Dec 2020 16:41:07 -0800 Subject: Remove DrawableStateImageButton This was unused. We're changing DrawableStateView in car-ui-lib, and this subclass was conflicting with that. Bug: 174522962 Test: Manually Change-Id: I80b083bb265faa381ce55007f456fcee064bd164 --- .../car/calendar/DrawableStateImageButton.java | 72 ---------------------- .../android/car/calendar/EventCalendarItem.java | 5 +- 2 files changed, 3 insertions(+), 74 deletions(-) delete mode 100644 src/com/android/car/calendar/DrawableStateImageButton.java (limited to 'src/com/android/car') 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..a7c023d 100644 --- a/src/com/android/car/calendar/EventCalendarItem.java +++ b/src/com/android/car/calendar/EventCalendarItem.java @@ -30,6 +30,7 @@ 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; @@ -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; -- cgit v1.2.3 From 6a802e58054c7c9da8245ba291c4d773b090543a Mon Sep 17 00:00:00 2001 From: John Patterson Date: Tue, 12 Jan 2021 15:34:45 +0100 Subject: Unbundle the Car Calendar App. Set the sdk_version to "system_current". Remove use of hidden APIs for phone number validation and formatting. Test: Manual testing on Hawk and updated and ran all tests. Bug: 176965230 Change-Id: Id33da19d10f0780c9f569dfd98957eb33fa6b00a --- .../android/car/calendar/CarCalendarActivity.java | 25 ++++++-- .../android/car/calendar/CarCalendarViewModel.java | 13 +++-- .../android/car/calendar/EventCalendarItem.java | 2 +- .../car/calendar/common/EventDescriptions.java | 68 ++++++++++------------ 4 files changed, 62 insertions(+), 46 deletions(-) (limited to 'src/com/android/car') diff --git a/src/com/android/car/calendar/CarCalendarActivity.java b/src/com/android/car/calendar/CarCalendarActivity.java index 97e2031..945482a 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; @@ -58,7 +59,10 @@ public class CarCalendarActivity extends FragmentActivity { // 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 +71,7 @@ public class CarCalendarActivity extends FragmentActivity { new CarCalendarViewModelFactory( mDependencies.mResolver, mDependencies.mLocale, + mDependencies.mTelephonyManager, mDependencies.mClock)) .get(CarCalendarViewModel.class); @@ -142,12 +147,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 +166,7 @@ public class CarCalendarActivity extends FragmentActivity { @NonNull @Override public T create(@NonNull Class aClass) { - return (T) new CarCalendarViewModel(mResolver, mLocale, mClock); + return (T) new CarCalendarViewModel(mResolver, mLocale, mTelephonyManager, mClock); } } @@ -163,11 +174,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/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/EventCalendarItem.java b/src/com/android/car/calendar/EventCalendarItem.java index a7c023d..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; @@ -34,6 +33,7 @@ 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; 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 "". private static final Pattern TEL_PIN_PATTERN = Pattern.compile(""); - 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 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 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); } } -- cgit v1.2.3 From a268e5e7195cc584c26047ddccf91c9688d9f914 Mon Sep 17 00:00:00 2001 From: Cole Faust Date: Fri, 15 Jan 2021 18:22:00 -0800 Subject: Update toolbar usage Use car-ui-lib's base layout style of toolbar, the old one is deprecated. Also in this cl: removing an unneccesary tag from the manifest, and using sdk_version: current instead of system_current to completely unbundle calendar. Fixes: 177703290 Test: atest CarCalendarUiTests CarCalendarUnitTests Change-Id: I5690b23933cb074acde6d4bc08f4d656f5c7db91 --- src/com/android/car/calendar/CarCalendarActivity.java | 5 +++++ 1 file changed, 5 insertions(+) (limited to 'src/com/android/car') diff --git a/src/com/android/car/calendar/CarCalendarActivity.java b/src/com/android/car/calendar/CarCalendarActivity.java index 945482a..94e0db6 100644 --- a/src/com/android/car/calendar/CarCalendarActivity.java +++ b/src/com/android/car/calendar/CarCalendarActivity.java @@ -33,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; @@ -56,6 +58,9 @@ 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( -- cgit v1.2.3