aboutsummaryrefslogtreecommitdiff
path: root/src/com
diff options
context:
space:
mode:
authorJohn Patterson <jdp@google.com>2020-07-01 12:45:04 +0200
committerJohn Patterson <jdp@google.com>2020-07-08 13:13:57 +0000
commita66655fd373cc9ef9ff06516a19141c60d2c2422 (patch)
tree1be2e60d2bcb1b3b37b3c24655a5ab8c0433f759 /src/com
parent41f8651ee86669c7607622b1207acb8a07bd66db (diff)
downloadCalendar-a66655fd373cc9ef9ff06516a19141c60d2c2422.tar.gz
Add CarCalendarAppandroid-vts-11.0_r9android-vts-11.0_r8android-vts-11.0_r7android-vts-11.0_r6android-vts-11.0_r5android-vts-11.0_r4android-vts-11.0_r3android-vts-11.0_r2android-vts-11.0_r16android-vts-11.0_r15android-vts-11.0_r14android-vts-11.0_r13android-vts-11.0_r12android-vts-11.0_r11android-vts-11.0_r10android-vts-11.0_r1android-security-11.0.0_r76android-security-11.0.0_r75android-security-11.0.0_r74android-security-11.0.0_r73android-security-11.0.0_r72android-security-11.0.0_r71android-security-11.0.0_r70android-security-11.0.0_r69android-security-11.0.0_r68android-security-11.0.0_r67android-security-11.0.0_r66android-security-11.0.0_r65android-security-11.0.0_r64android-security-11.0.0_r63android-security-11.0.0_r62android-security-11.0.0_r61android-security-11.0.0_r60android-security-11.0.0_r59android-security-11.0.0_r58android-security-11.0.0_r57android-security-11.0.0_r56android-security-11.0.0_r55android-security-11.0.0_r54android-security-11.0.0_r53android-security-11.0.0_r52android-security-11.0.0_r51android-security-11.0.0_r50android-security-11.0.0_r49android-security-11.0.0_r1android-cts-11.0_r9android-cts-11.0_r8android-cts-11.0_r7android-cts-11.0_r6android-cts-11.0_r5android-cts-11.0_r4android-cts-11.0_r3android-cts-11.0_r2android-cts-11.0_r16android-cts-11.0_r15android-cts-11.0_r14android-cts-11.0_r13android-cts-11.0_r12android-cts-11.0_r11android-cts-11.0_r10android-cts-11.0_r1android-11.0.0_r5android-11.0.0_r4android-11.0.0_r3android-11.0.0_r25android-11.0.0_r2android-11.0.0_r17android-11.0.0_r1android11-tests-releaseandroid11-security-releaseandroid11-s1-releaseandroid11-releaseandroid11-dev
This was moved from vendor/auto/embedded/apps/Calendar Header licence was updated in all files Added OWNERS file Bug: 140862425 Bug: 160135021 Merged-In: I64cca6684ba132f47308911bdc73f35fe2176502 Test: clean build from root still works Change-Id: I64cca6684ba132f47308911bdc73f35fe2176502
Diffstat (limited to 'src/com')
-rw-r--r--src/com/android/car/calendar/AllDayEventsItem.java111
-rw-r--r--src/com/android/car/calendar/CalendarItem.java60
-rw-r--r--src/com/android/car/calendar/CarCalendarActivity.java173
-rw-r--r--src/com/android/car/calendar/CarCalendarView.java204
-rw-r--r--src/com/android/car/calendar/CarCalendarViewModel.java71
-rw-r--r--src/com/android/car/calendar/DrawableStateImageButton.java72
-rw-r--r--src/com/android/car/calendar/EventCalendarItem.java314
-rw-r--r--src/com/android/car/calendar/TitleCalendarItem.java65
-rw-r--r--src/com/android/car/calendar/common/CalendarFormatter.java115
-rw-r--r--src/com/android/car/calendar/common/Dialer.java98
-rw-r--r--src/com/android/car/calendar/common/Event.java135
-rw-r--r--src/com/android/car/calendar/common/EventDescriptions.java119
-rw-r--r--src/com/android/car/calendar/common/EventLocations.java30
-rw-r--r--src/com/android/car/calendar/common/EventsLiveData.java305
-rw-r--r--src/com/android/car/calendar/common/Navigator.java56
15 files changed, 1928 insertions, 0 deletions
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<EventCalendarItem> mAllDayEventItems;
+
+ AllDayEventsItem(List<EventCalendarItem> 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<EventCalendarItem> 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..97e2031
--- /dev/null
+++ b/src/com/android/car/calendar/CarCalendarActivity.java
@@ -0,0 +1,173 @@
+/*
+ * 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<String, Runnable> mPermissionToCallbacks = HashMultimap.create();
+
+ // Allows tests to replace certain dependencies.
+ @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,
+ 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.
+ *
+ * <p>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<Runnable> 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 extends ViewModel> T create(@NonNull Class<T> 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<CalendarItem> mRecyclerViewItems = new ArrayList<>();
+
+ private final RecyclerView.Adapter mAdapter = new EventRecyclerViewAdapter();
+ private final Observer<ImmutableList<Event>> mEventsObserver =
+ events -> {
+ if (DEBUG) Log.d(TAG, "Events changed");
+ updateRecyclerViewItems(events);
+
+ // TODO(jdp) Only change the affected items (DiffUtil) to allow animated changes.
+ mAdapter.notifyDataSetChanged();
+ };
+
+ 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<Event> 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<CalendarItem> eventItems = null;
+ List<EventCalendarItem> 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<EventCalendarItem> allDayEventItems, List<CalendarItem> 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 "<tel: ... >".
+ private static final Pattern TEL_PIN_PATTERN =
+ Pattern.compile("<tel:(\\+?[\\d -]{6,})([\\d,;#*]{3,})?>");
+
+ private static final PhoneNumberUtil PHONE_NUMBER_UTIL = PhoneNumberUtil.getInstance();
+
+ // Ensure numbers are over 5 digits to reduce false positives.
+ private static final int MIN_NATIONAL_NUMBER = 10_000;
+
+ private final Locale mLocale;
+
+ public EventDescriptions(Locale locale) {
+ mLocale = locale;
+ }
+
+ /** Find conference call data embedded in the description. */
+ public List<NumberAndAccess> extractNumberAndPins(String descriptionText) {
+ String decoded = Uri.decode(descriptionText);
+
+ Map<String, NumberAndAccess> 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<String, NumberAndAccess> 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 <a
+ * href="https://developer.android.com/guide/topics/providers/calendar-provider">Calendar
+ * Provider</a>.
+ *
+ * <p>While in the active state the content provider is observed for changes.
+ */
+public class EventsLiveData extends LiveData<ImmutableList<Event>> {
+
+ 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> 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<Event> 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<Event> events = new ArrayList<>();
+ while (mEventsCursor.moveToNext()) {
+ List<Event> 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<Event> 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<Dialer.NumberAndAccess> 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<Event> 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();
+ }
+ }
+}