aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorJohn Patterson <jdp@google.com>2020-07-08 21:40:57 +0000
committerAutomerger Merge Worker <android-build-automerger-merge-worker@system.gserviceaccount.com>2020-07-08 21:40:57 +0000
commitedbacaf85d5161048343228350eec56ec3e60318 (patch)
tree1be2e60d2bcb1b3b37b3c24655a5ab8c0433f759
parent41f8651ee86669c7607622b1207acb8a07bd66db (diff)
parenta66655fd373cc9ef9ff06516a19141c60d2c2422 (diff)
downloadCalendar-edbacaf85d5161048343228350eec56ec3e60318.tar.gz
Original change: https://googleplex-android-review.googlesource.com/c/platform/packages/apps/Car/Calendar/+/12098146 Change-Id: I4ef78859f4cf098b762364debb54d18299f2c1cc
-rw-r--r--Android.bp37
-rw-r--r--AndroidManifest.xml55
-rw-r--r--OWNERS4
-rw-r--r--README.md10
-rw-r--r--res/drawable/ic_calendar_sync.xml91
-rw-r--r--res/drawable/ic_navigation_expand_less_white_24dp.xml24
-rw-r--r--res/drawable/ic_navigation_expand_more_white_24dp.xml24
-rw-r--r--res/drawable/ic_navigation_gm2_24px.xml24
-rw-r--r--res/drawable/ic_phone_gm2_24px.xml24
-rw-r--r--res/layout/all_day_events_item.xml60
-rw-r--r--res/layout/calendar.xml47
-rw-r--r--res/layout/event_item.xml71
-rw-r--r--res/layout/title_item.xml36
-rw-r--r--res/values/colors.xml20
-rw-r--r--res/values/dimens.xml20
-rw-r--r--res/values/ic_launcher_background.xml18
-rw-r--r--res/values/strings.xml48
-rw-r--r--res/values/styles.xml37
-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
-rw-r--r--tests/ui/Android.bp38
-rw-r--r--tests/ui/AndroidManifest.xml34
-rw-r--r--tests/ui/src/com/android/car/calendar/CarCalendarUiTest.java303
-rw-r--r--tests/unit/Android.bp37
-rw-r--r--tests/unit/AndroidManifest.xml34
-rw-r--r--tests/unit/src/com/android/car/calendar/common/CalendarFormatterTest.java98
-rw-r--r--tests/unit/src/com/android/car/calendar/common/EventDescriptionsTest.java131
-rw-r--r--tests/unit/src/com/android/car/calendar/common/EventLocationsTest.java60
-rw-r--r--tests/unit/src/com/android/car/calendar/common/EventsLiveDataTest.java609
42 files changed, 3922 insertions, 0 deletions
diff --git a/Android.bp b/Android.bp
new file mode 100644
index 0000000..e79e37b
--- /dev/null
+++ b/Android.bp
@@ -0,0 +1,37 @@
+// Copyright (C) 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.
+//
+
+android_app {
+ name: "CarCalendarApp",
+ srcs: ["src/**/*.java"],
+ resource_dirs: ["res"],
+ platform_apis: true,
+ optimize: {
+ enabled: false,
+ },
+ dex_preopt: {
+ enabled: false,
+ },
+ aaptflags: ["--auto-add-overlay"],
+ static_libs: [
+ "car-ui-lib",
+ "androidx.lifecycle_lifecycle-extensions",
+ "androidx.appcompat_appcompat",
+ "androidx.lifecycle_lifecycle-livedata",
+ "androidx.lifecycle_lifecycle-viewmodel",
+ "guava",
+ ],
+ libs: ["android.car"],
+}
diff --git a/AndroidManifest.xml b/AndroidManifest.xml
new file mode 100644
index 0000000..4921dae
--- /dev/null
+++ b/AndroidManifest.xml
@@ -0,0 +1,55 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+ ~ Copyright (C) 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.
+-->
+<manifest xmlns:android="http://schemas.android.com/apk/res/android"
+ xmlns:tools="http://schemas.android.com/tools"
+ package="com.android.car.calendar">
+
+ <uses-sdk
+ android:minSdkVersion="28"
+ android:targetSdkVersion="29"/>
+
+ <uses-permission android:name="android.permission.READ_CALENDAR" />
+ <uses-permission android:name="android.permission.CALL_PHONE" />
+
+ <application
+ android:allowBackup="true"
+ android:icon="@drawable/ic_calendar_sync"
+ android:label="@string/app_name"
+ android:theme="@style/Theme.CarUi"
+ android:supportsRtl="true">
+
+ <activity android:name=".CarCalendarActivity"
+ android:launchMode="singleTask">
+ <intent-filter>
+ <action android:name="android.intent.action.MAIN"/>
+ <category android:name="android.intent.category.DEFAULT"/>
+ <category android:name="android.intent.category.APP_CALENDAR"/>
+ <category android:name="android.intent.category.LAUNCHER"/>
+ </intent-filter>
+ </activity>
+
+ <!-- Work around b/113294940. -->
+ <provider
+ android:name="androidx.lifecycle.ProcessLifecycleOwnerInitializer"
+ tools:replace="android:authorities"
+ android:authorities="${applicationId}.lifecycle-tests"
+ android:exported="false"
+ android:multiprocess="true" />
+
+ </application>
+
+</manifest>
diff --git a/OWNERS b/OWNERS
new file mode 100644
index 0000000..531468a
--- /dev/null
+++ b/OWNERS
@@ -0,0 +1,4 @@
+# Default code reviewers picked from top 3 or more developers.
+# Please update this list if you find better candidates.
+jdp@google.com
+haamel@google.com
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..6e0122b
--- /dev/null
+++ b/README.md
@@ -0,0 +1,10 @@
+# Car Calendar App
+
+Open Source reference application for viewing calendar events on Android Automotive OS.
+
+## Build and install
+
+```bash
+m -j CarCalendarApp
+adb install -r ${out}/system/app/CarCalendarApp/CarCalendarApp.apk
+```
diff --git a/res/drawable/ic_calendar_sync.xml b/res/drawable/ic_calendar_sync.xml
new file mode 100644
index 0000000..b527dfd
--- /dev/null
+++ b/res/drawable/ic_calendar_sync.xml
@@ -0,0 +1,91 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- Copyright (C) 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.
+-->
+
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+ android:width="256dp"
+ android:height="256dp"
+ android:viewportWidth="256"
+ android:viewportHeight="256">
+ <path
+ android:pathData="M16,8L240,8A8,8 0,0 1,248 16L248,240A8,8 0,0 1,240 248L16,248A8,8 0,0 1,8 240L8,16A8,8 0,0 1,16 8z"
+ android:fillColor="#F9F9F9"/>
+ <path
+ android:pathData="M16,7L240,7A9,9 0,0 1,249 16L249,240A9,9 0,0 1,240 249L16,249A9,9 0,0 1,7 240L7,16A9,9 0,0 1,16 7z"
+ android:strokeAlpha="0.1"
+ android:strokeWidth="2"
+ android:fillColor="#00000000"
+ android:strokeColor="#000000"/>
+ <path
+ android:pathData="M248,57H249V56V16C249,11.029 244.971,7 240,7H16C11.029,7 7,11.029 7,16V56V57H8H248Z"
+ android:strokeWidth="2"
+ android:fillColor="#F6BF26"
+ android:strokeColor="#F6BF26"/>
+ <path
+ android:pathData="M6,58h244v2h-244z"
+ android:strokeAlpha="0.2"
+ android:fillColor="#000000"
+ android:fillAlpha="0.2"/>
+ <path
+ android:pathData="M6,60h244v2h-244z"
+ android:strokeAlpha="0.1"
+ android:fillColor="#000000"
+ android:fillAlpha="0.1"/>
+ <path
+ android:pathData="M36,121h40v24h-40z"
+ android:fillColor="#C0C0C0"/>
+ <path
+ android:pathData="M36,161h40v24h-40z"
+ android:fillColor="#C0C0C0"/>
+ <path
+ android:pathData="M36,201h40v24h-40z"
+ android:fillColor="#C0C0C0"/>
+ <path
+ android:pathData="M84,81h40v24h-40z"
+ android:fillColor="#C0C0C0"/>
+ <path
+ android:pathData="M84,121h40v24h-40z"
+ android:fillColor="#C0C0C0"/>
+ <path
+ android:pathData="M84,161h40v24h-40z"
+ android:fillColor="#C0C0C0"/>
+ <path
+ android:pathData="M84,201h40v24h-40z"
+ android:fillColor="#C0C0C0"/>
+ <path
+ android:pathData="M132,81h40v24h-40z"
+ android:fillColor="#C0C0C0"/>
+ <path
+ android:pathData="M132,121h40v24h-40z"
+ android:fillColor="#C0C0C0"/>
+ <path
+ android:pathData="M132,161h40v24h-40z"
+ android:fillColor="#C0C0C0"/>
+ <path
+ android:pathData="M132,201h40v24h-40z"
+ android:fillColor="#C0C0C0"/>
+ <path
+ android:pathData="M180,81h40v24h-40z"
+ android:fillColor="#C0C0C0"/>
+ <path
+ android:pathData="M180,121h40v24h-40z"
+ android:fillColor="#C0C0C0"/>
+ <path
+ android:pathData="M180,161h40v24h-40z"
+ android:fillColor="#C0C0C0"/>
+ <path
+ android:pathData="M180,201h40v24h-40z"
+ android:fillColor="#F6BF26"/>
+</vector>
diff --git a/res/drawable/ic_navigation_expand_less_white_24dp.xml b/res/drawable/ic_navigation_expand_less_white_24dp.xml
new file mode 100644
index 0000000..a7b9522
--- /dev/null
+++ b/res/drawable/ic_navigation_expand_less_white_24dp.xml
@@ -0,0 +1,24 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- Copyright (C) 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.
+-->
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+ android:width="24dp"
+ android:height="24dp"
+ android:viewportWidth="24"
+ android:viewportHeight="24">
+ <path
+ android:pathData="M12,8l-6,6 1.41,1.41L12,10.83l4.59,4.58L18,14z"
+ android:fillColor="#FFFFFF"/>
+</vector>
diff --git a/res/drawable/ic_navigation_expand_more_white_24dp.xml b/res/drawable/ic_navigation_expand_more_white_24dp.xml
new file mode 100644
index 0000000..8aba65f
--- /dev/null
+++ b/res/drawable/ic_navigation_expand_more_white_24dp.xml
@@ -0,0 +1,24 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- Copyright (C) 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.
+-->
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+ android:width="24dp"
+ android:height="24dp"
+ android:viewportWidth="24"
+ android:viewportHeight="24">
+ <path
+ android:pathData="M16.59,8.59L12,13.17 7.41,8.59 6,10l6,6 6,-6z"
+ android:fillColor="#FFFFFF"/>
+</vector>
diff --git a/res/drawable/ic_navigation_gm2_24px.xml b/res/drawable/ic_navigation_gm2_24px.xml
new file mode 100644
index 0000000..b945227
--- /dev/null
+++ b/res/drawable/ic_navigation_gm2_24px.xml
@@ -0,0 +1,24 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- Copyright (C) 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.
+-->
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+ android:width="24dp"
+ android:height="24dp"
+ android:viewportWidth="24"
+ android:viewportHeight="24">
+ <path
+ android:fillColor="#FF000000"
+ android:pathData="M12,7.27l4.28,10.43 -3.47,-1.53 -0.81,-0.36 -0.81,0.36 -3.47,1.53L12,7.27M12,2L4.5,20.29l0.71,0.71L12,18l6.79,3 0.71,-0.71L12,2z"/>
+</vector>
diff --git a/res/drawable/ic_phone_gm2_24px.xml b/res/drawable/ic_phone_gm2_24px.xml
new file mode 100644
index 0000000..669fde2
--- /dev/null
+++ b/res/drawable/ic_phone_gm2_24px.xml
@@ -0,0 +1,24 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- Copyright (C) 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.
+-->
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+ android:width="24dp"
+ android:height="24dp"
+ android:viewportWidth="24"
+ android:viewportHeight="24">
+ <path
+ android:fillColor="#FF000000"
+ android:pathData="M16.01,14.46l-2.62,2.62c-2.75,-1.49 -5.01,-3.75 -6.5,-6.5l2.62,-2.62c0.24,-0.24 0.34,-0.58 0.27,-0.9L9.13,3.8c-0.09,-0.46 -0.5,-0.8 -0.98,-0.8H4c-0.56,0 -1.03,0.47 -1,1.03 0.17,2.91 1.04,5.63 2.43,8.01 1.57,2.69 3.81,4.93 6.5,6.5 2.38,1.39 5.1,2.26 8.01,2.43 0.56,0.03 1.03,-0.44 1.03,-1v-4.15c0,-0.48 -0.34,-0.89 -0.8,-0.98l-3.26,-0.65c-0.33,-0.07 -0.67,0.04 -0.9,0.27z"/>
+</vector>
diff --git a/res/layout/all_day_events_item.xml b/res/layout/all_day_events_item.xml
new file mode 100644
index 0000000..7cf5569
--- /dev/null
+++ b/res/layout/all_day_events_item.xml
@@ -0,0 +1,60 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- Copyright (C) 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.
+-->
+<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:orientation="vertical">
+
+ <LinearLayout
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:paddingBottom="16dp"
+ android:orientation="horizontal">
+
+ <TextView
+ android:id="@+id/title_text"
+ android:layout_weight="1"
+ android:layout_width="0dp"
+ android:layout_height="wrap_content"
+ style="@style/EventTitleText"/>
+
+ <FrameLayout
+ android:id="@+id/expand_collapse"
+ android:layout_width="@dimen/car_ui_list_item_height"
+ android:layout_height="match_parent"
+ android:background="?android:attr/selectableItemBackground">
+
+ <ImageView
+ android:id="@+id/expand_collapse_icon"
+ android:layout_gravity="center"
+ android:layout_width="@dimen/car_calendar_expand_collapse_size"
+ android:layout_height="@dimen/car_calendar_expand_collapse_size"/>
+ </FrameLayout>
+ </LinearLayout>
+
+ <LinearLayout
+ android:id="@+id/events"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:orientation="vertical" />
+
+ <View
+ android:layout_marginBottom="16dp"
+ android:layout_width="match_parent"
+ android:layout_height="1.6dp"
+ android:background="@color/car_calendar_allday_divider_color"/>
+
+</LinearLayout>
diff --git a/res/layout/calendar.xml b/res/layout/calendar.xml
new file mode 100644
index 0000000..4c31ddc
--- /dev/null
+++ b/res/layout/calendar.xml
@@ -0,0 +1,47 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- Copyright (C) 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.
+-->
+<LinearLayout
+ xmlns:android="http://schemas.android.com/apk/res/android"
+ xmlns:app="http://schemas.android.com/apk/res-auto"
+ android:layout_height="match_parent"
+ android:layout_width="match_parent"
+ android:orientation="vertical">
+
+ <com.android.car.ui.toolbar.Toolbar
+ android:id="@+id/toolbar"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ app:title="@string/app_name"
+ />
+
+ <FrameLayout android:layout_width="match_parent" android:layout_height="match_parent">
+ <com.android.car.ui.recyclerview.CarUiRecyclerView
+ android:id="@+id/events"
+ android:layout_width="match_parent"
+ android:layout_height="match_parent"
+ />
+
+ <TextView
+ android:id="@+id/no_events_text"
+ android:layout_width="match_parent"
+ android:layout_height="match_parent"
+ android:layout_margin="@dimen/car_ui_list_item_start_inset"
+ android:gravity="center"
+ android:textAppearance="@style/NoEventsText"/>
+
+ </FrameLayout>
+
+</LinearLayout>
diff --git a/res/layout/event_item.xml b/res/layout/event_item.xml
new file mode 100644
index 0000000..68dda47
--- /dev/null
+++ b/res/layout/event_item.xml
@@ -0,0 +1,71 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+ Copyright (C) 2019 Google Inc.
+
+ 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.
+ -->
+
+<LinearLayout
+ xmlns:android="http://schemas.android.com/apk/res/android"
+ android:orientation="horizontal"
+ android:layout_width="match_parent"
+ android:layout_height="@dimen/car_ui_list_item_height">
+
+ <!-- Title and description. -->
+ <LinearLayout
+ android:orientation="vertical"
+ android:layout_width="0dp"
+ android:layout_weight="1"
+ android:paddingStart="@dimen/car_ui_list_item_start_inset"
+ android:paddingHorizontal="@dimen/car_ui_padding_2"
+ android:layout_height="wrap_content">
+
+ <TextView
+ android:id="@+id/event_title"
+ android:layout_marginBottom="@dimen/car_ui_padding_0"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ style="@style/EventTitleText"/>
+
+ <TextView
+ android:id="@+id/description_text"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ style="@style/EventDetailText"/>
+
+ </LinearLayout>
+
+ <!-- Secondary action icon. -->
+ <com.android.car.calendar.DrawableStateImageButton
+ android:id="@+id/primary_action_button"
+ android:layout_width="@dimen/car_ui_list_item_height"
+ android:layout_height="@dimen/car_ui_list_item_height"
+ android:scaleType="fitCenter"
+ android:tint="@color/car_calendar_action_icon_color"
+ android:background="?android:attr/selectableItemBackground"
+ android:padding="@dimen/car_ui_padding_4"
+ android:layout_gravity="center"/>
+
+ <!-- Secondary action icon. -->
+ <com.android.car.calendar.DrawableStateImageButton
+ android:id="@+id/secondary_action_button"
+ android:layout_width="@dimen/car_ui_list_item_height"
+ android:layout_height="@dimen/car_ui_list_item_height"
+ android:scaleType="fitCenter"
+ android:tint="@color/car_calendar_action_icon_color"
+ android:background="?android:attr/selectableItemBackground"
+ android:padding="@dimen/car_ui_padding_4"
+ android:layout_gravity="center"/>
+
+</LinearLayout>
+
diff --git a/res/layout/title_item.xml b/res/layout/title_item.xml
new file mode 100644
index 0000000..9be5c3b
--- /dev/null
+++ b/res/layout/title_item.xml
@@ -0,0 +1,36 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+ Copyright (C) 2019 Google Inc.
+
+ 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.
+ -->
+
+<LinearLayout
+ xmlns:android="http://schemas.android.com/apk/res/android"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:paddingHorizontal="@dimen/car_ui_list_item_start_inset"
+ android:paddingTop="@dimen/car_ui_padding_4"
+ android:paddingBottom="@dimen/car_ui_padding_1"
+ android:gravity="bottom"
+ >
+
+ <TextView
+ style="@style/DayTitleText"
+ android:id="@+id/date_text"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:layout_gravity="bottom"
+ />
+
+</LinearLayout>
diff --git a/res/values/colors.xml b/res/values/colors.xml
new file mode 100644
index 0000000..30fddf3
--- /dev/null
+++ b/res/values/colors.xml
@@ -0,0 +1,20 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- Copyright (C) 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.
+-->
+<resources>
+ <color name="car_calendar_action_divider_color">#33ffffff</color>
+ <color name="car_calendar_allday_divider_color">#ffffffff</color>
+ <color name="car_calendar_action_icon_color">@color/car_ui_toolbar_menu_item_icon_color</color>
+</resources> \ No newline at end of file
diff --git a/res/values/dimens.xml b/res/values/dimens.xml
new file mode 100644
index 0000000..b9a489f
--- /dev/null
+++ b/res/values/dimens.xml
@@ -0,0 +1,20 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- Copyright (C) 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.
+-->
+<resources>
+ <dimen name="car_calendar_time_width">192dp</dimen>
+ <dimen name="car_calendar_indicator_width">18dp</dimen>
+ <dimen name="car_calendar_expand_collapse_size">48dp</dimen>
+</resources> \ No newline at end of file
diff --git a/res/values/ic_launcher_background.xml b/res/values/ic_launcher_background.xml
new file mode 100644
index 0000000..0d602f7
--- /dev/null
+++ b/res/values/ic_launcher_background.xml
@@ -0,0 +1,18 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- Copyright (C) 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.
+-->
+<resources>
+ <color name="ic_launcher_background">#3479BB</color>
+</resources> \ No newline at end of file
diff --git a/res/values/strings.xml b/res/values/strings.xml
new file mode 100644
index 0000000..a7f7c74
--- /dev/null
+++ b/res/values/strings.xml
@@ -0,0 +1,48 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- Copyright (C) 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.
+-->
+<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
+ <!-- Name of the application [CHAR LIMIT=30] -->
+ <string name="app_name">Calendar</string>
+
+ <!-- Toast error message when no dialer app found to make a call [CHAR LIMIT=30] -->
+ <string name="no_dialler">No dialer available</string>
+
+ <!-- Shown when there are no events to show. [CHAR LIMIT=200] -->
+ <string name="no_events">No scheduled events. You\'re free!</string>
+
+ <!-- Shown when there are no calendars to show. [CHAR LIMIT=200] -->
+ <string name="no_calendars">Calendar may be starting up, or you may need to check your settings in the Companion App</string>
+
+ <!-- The text shown instead of a start / end time for all-day events [CHAR LIMIT=30] -->
+ <string name="all_day_event">All day</string>
+
+ <!-- The conference call description [CHAR LIMIT=50] -->
+ <string name="phone_number">
+ <xliff:g id="number" example="+49 (0) 555 1234567">%1$s</xliff:g>
+ </string>
+
+ <!-- The conference call description [CHAR LIMIT=50] -->
+ <string name="phone_number_with_pin">
+ <xliff:g id="number" example="+49 (0) 555 1234567">%1$s</xliff:g>
+ PIN:
+ <xliff:g id="pin" example="4567">%2$s</xliff:g>
+ </string>
+
+ <!-- The title for the all-day events section. Only shown for more than one item. [CHAR LIMIT=120] -->
+ <plurals name="all_day_title">
+ <item quantity="other">%d all day events</item>
+ </plurals>
+</resources> \ No newline at end of file
diff --git a/res/values/styles.xml b/res/values/styles.xml
new file mode 100644
index 0000000..04fdb66
--- /dev/null
+++ b/res/values/styles.xml
@@ -0,0 +1,37 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- Copyright (C) 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.
+-->
+<resources xmlns:android="http://schemas.android.com/apk/res/android">
+
+ <style name="EventTitleText" parent="TextAppearance.CarUi.ListItem">
+ <item name="android:maxLines">1</item>
+ <item name="android:ellipsize">end</item>
+ </style>
+
+ <style name="EventDetailText" parent="TextAppearance.CarUi.ListItem.Body">
+ <item name="android:maxLines">1</item>
+ <item name="android:ellipsize">end</item>
+ </style>
+
+ <style name="DayTitleText" parent="TextAppearance.CarUi.ListItem.Body">
+ <item name="android:maxLines">1</item>
+ <item name="android:ellipsize">end</item>
+ </style>
+
+ <style name="NoEventsText" parent="TextAppearance.CarUi">
+ <item name="android:textSize">@dimen/car_ui_body1_size</item>
+ </style>
+
+</resources>
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();
+ }
+ }
+}
diff --git a/tests/ui/Android.bp b/tests/ui/Android.bp
new file mode 100644
index 0000000..829e312
--- /dev/null
+++ b/tests/ui/Android.bp
@@ -0,0 +1,38 @@
+// Copyright (C) 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.
+//
+
+android_test {
+ name: "CarCalendarUiTests",
+ srcs: ["src/**/*.java"],
+ instrumentation_for: "CarCalendarApp",
+ optimize: {
+ enabled: false,
+ },
+ sdk_version: "system_current",
+ static_libs: [
+ "androidx.annotation_annotation",
+ "androidx.test.espresso.core",
+ "androidx.test.ext.junit",
+ "androidx.test.rules",
+ "androidx.test.runner",
+ "mockito-target",
+ "truth-prebuilt",
+ ],
+ libs: [
+ "android.test.base.stubs",
+ "android.test.mock.stubs",
+ "android.test.runner.stubs",
+ ],
+}
diff --git a/tests/ui/AndroidManifest.xml b/tests/ui/AndroidManifest.xml
new file mode 100644
index 0000000..21b126e
--- /dev/null
+++ b/tests/ui/AndroidManifest.xml
@@ -0,0 +1,34 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+ ~ Copyright (C) 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.
+-->
+<manifest xmlns:android="http://schemas.android.com/apk/res/android"
+ package="com.android.car.calendar.tests.ui" >
+
+ <uses-sdk android:targetSdkVersion="28" android:minSdkVersion="23" />
+
+ <uses-permission android:name="android.permission.READ_CALENDAR" />
+ <uses-permission android:name="android.permission.CALL_PHONE" />
+
+ <instrumentation
+ android:name="androidx.test.runner.AndroidJUnitRunner"
+ android:label="Car Calendar UI Tests"
+ android:targetPackage="com.android.car.calendar" />
+
+ <application android:label="CarCalendarUiTests" >
+ <uses-library android:name="android.test.runner" />
+ </application>
+
+</manifest>
diff --git a/tests/ui/src/com/android/car/calendar/CarCalendarUiTest.java b/tests/ui/src/com/android/car/calendar/CarCalendarUiTest.java
new file mode 100644
index 0000000..5c7883c
--- /dev/null
+++ b/tests/ui/src/com/android/car/calendar/CarCalendarUiTest.java
@@ -0,0 +1,303 @@
+/*
+ * 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 androidx.test.espresso.Espresso.onView;
+import static androidx.test.espresso.action.ViewActions.click;
+import static androidx.test.espresso.assertion.ViewAssertions.doesNotExist;
+import static androidx.test.espresso.assertion.ViewAssertions.matches;
+import static androidx.test.espresso.matcher.ViewMatchers.isDisplayed;
+import static androidx.test.espresso.matcher.ViewMatchers.withId;
+import static androidx.test.espresso.matcher.ViewMatchers.withText;
+
+import static org.hamcrest.CoreMatchers.not;
+
+import android.Manifest;
+import android.app.Activity;
+import android.content.Context;
+import android.database.Cursor;
+import android.database.MatrixCursor;
+import android.net.Uri;
+import android.os.Bundle;
+import android.os.CancellationSignal;
+import android.provider.CalendarContract;
+import android.test.mock.MockContentProvider;
+import android.test.mock.MockContentResolver;
+
+import androidx.lifecycle.Observer;
+import androidx.lifecycle.ViewModelProvider;
+import androidx.test.core.app.ActivityScenario;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import androidx.test.filters.LargeTest;
+import androidx.test.platform.app.InstrumentationRegistry;
+import androidx.test.rule.GrantPermissionRule;
+import androidx.test.runner.lifecycle.ActivityLifecycleCallback;
+import androidx.test.runner.lifecycle.ActivityLifecycleMonitorRegistry;
+import androidx.test.runner.lifecycle.Stage;
+
+import com.android.car.calendar.common.Event;
+import com.android.car.calendar.common.EventsLiveData;
+
+import com.google.common.collect.ImmutableList;
+
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+import java.time.Clock;
+import java.time.LocalDateTime;
+import java.time.ZoneId;
+import java.time.ZonedDateTime;
+import java.time.temporal.ChronoUnit;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Locale;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+
+@LargeTest
+@RunWith(AndroidJUnit4.class)
+public class CarCalendarUiTest {
+ private static final ZoneId BERLIN_ZONE_ID = ZoneId.of("Europe/Berlin");
+ private static final ZoneId UTC_ZONE_ID = ZoneId.of("UTC");
+ private static final Locale LOCALE = Locale.ENGLISH;
+ private static final ZonedDateTime CURRENT_DATE_TIME =
+ LocalDateTime.of(2019, 12, 10, 10, 10, 10, 500500).atZone(BERLIN_ZONE_ID);
+ private static final ZonedDateTime START_DATE_TIME =
+ CURRENT_DATE_TIME.truncatedTo(ChronoUnit.HOURS);
+ private static final String EVENT_TITLE = "the title";
+ private static final String EVENT_LOCATION = "the location";
+ private static final String EVENT_DESCRIPTION = "the description";
+ private static final String CALENDAR_NAME = "the calendar name";
+ private static final int CALENDAR_COLOR = 0xCAFEBABE;
+ private static final int EVENT_ATTENDEE_STATUS =
+ CalendarContract.Attendees.ATTENDEE_STATUS_ACCEPTED;
+
+ private final ActivityLifecycleCallback mLifecycleCallback = this::onActivityLifecycleChanged;
+
+ @Rule
+ public final GrantPermissionRule permissionRule =
+ GrantPermissionRule.grant(Manifest.permission.READ_CALENDAR);
+
+ private List<Object[]> mTestEventRows;
+
+ // These can be set in the test thread and read on the main thread.
+ private volatile CountDownLatch mEventChangesLatch;
+
+ @Before
+ public void setUp() {
+ ActivityLifecycleMonitorRegistry.getInstance().addLifecycleCallback(mLifecycleCallback);
+ mTestEventRows = new ArrayList<>();
+ }
+
+ private void onActivityLifecycleChanged(Activity activity, Stage stage) {
+ if (stage.equals(Stage.PRE_ON_CREATE)) {
+ setActivityDependencies((CarCalendarActivity) activity);
+ } else if (stage.equals(Stage.CREATED)) {
+ observeEventsLiveData((CarCalendarActivity) activity);
+ }
+ }
+
+ private void setActivityDependencies(CarCalendarActivity activity) {
+ Clock fixedTimeClock = Clock.fixed(CURRENT_DATE_TIME.toInstant(), BERLIN_ZONE_ID);
+ Context context = InstrumentationRegistry.getInstrumentation().getTargetContext();
+ MockContentResolver mockContentResolver = new MockContentResolver(context);
+ TestCalendarContentProvider testCalendarContentProvider =
+ new TestCalendarContentProvider(context);
+ mockContentResolver.addProvider(CalendarContract.AUTHORITY, testCalendarContentProvider);
+ activity.mDependencies =
+ new CarCalendarActivity.Dependencies(LOCALE, fixedTimeClock, mockContentResolver);
+ }
+
+ private void observeEventsLiveData(CarCalendarActivity activity) {
+ CarCalendarViewModel carCalendarViewModel =
+ new ViewModelProvider(activity).get(CarCalendarViewModel.class);
+ EventsLiveData eventsLiveData = carCalendarViewModel.getEventsLiveData();
+ mEventChangesLatch = new CountDownLatch(1);
+
+ // Notifications occur on the main thread.
+ eventsLiveData.observeForever(
+ new Observer<ImmutableList<Event>>() {
+ // Ignore the first change event triggered on registration with default value.
+ boolean mIgnoredFirstChange;
+
+ @Override
+ public void onChanged(ImmutableList<Event> events) {
+ if (mIgnoredFirstChange) {
+ // Signal that the events were changed and notified on main thread.
+ mEventChangesLatch.countDown();
+ }
+ mIgnoredFirstChange = true;
+ }
+ });
+ }
+
+ @After
+ public void tearDown() {
+ ActivityLifecycleMonitorRegistry.getInstance().removeLifecycleCallback(mLifecycleCallback);
+ }
+
+ @Test
+ public void calendar_titleShows() {
+ try (ActivityScenario<CarCalendarActivity> ignored =
+ ActivityScenario.launch(CarCalendarActivity.class)) {
+ onView(withText(R.string.app_name)).check(matches(isDisplayed()));
+ }
+ }
+
+ @Test
+ public void event_displayed() {
+ mTestEventRows.add(buildTestRow(START_DATE_TIME, 1, EVENT_TITLE, false));
+ try (ActivityScenario<CarCalendarActivity> ignored =
+ ActivityScenario.launch(CarCalendarActivity.class)) {
+ waitForEventsChange();
+
+ // Wait for the UI to be updated with changed events.
+ InstrumentationRegistry.getInstrumentation().waitForIdleSync();
+
+ onView(withText(EVENT_TITLE)).check(matches(isDisplayed()));
+ }
+ }
+
+ @Test
+ public void singleAllDayEvent_notCollapsed() {
+ // All day events are stored in UTC time.
+ ZonedDateTime utcDayStartTime =
+ START_DATE_TIME.withZoneSameInstant(UTC_ZONE_ID).truncatedTo(ChronoUnit.DAYS);
+
+ mTestEventRows.add(buildTestRow(utcDayStartTime, 24, EVENT_TITLE, true));
+
+ try (ActivityScenario<CarCalendarActivity> ignored =
+ ActivityScenario.launch(CarCalendarActivity.class)) {
+ waitForEventsChange();
+
+ // Wait for the UI to be updated with changed events.
+ InstrumentationRegistry.getInstrumentation().waitForIdleSync();
+
+ // A single all-day event should not be collapsible.
+ onView(withId(R.id.expand_collapse_icon)).check(doesNotExist());
+ onView(withText(EVENT_TITLE)).check(matches(isDisplayed()));
+ }
+ }
+
+ @Test
+ public void multipleAllDayEvents_collapsed() {
+ mTestEventRows.add(buildTestRowAllDay(EVENT_TITLE));
+ mTestEventRows.add(buildTestRowAllDay("Another all day event"));
+
+ try (ActivityScenario<CarCalendarActivity> ignored =
+ ActivityScenario.launch(CarCalendarActivity.class)) {
+ waitForEventsChange();
+
+ // Wait for the UI to be updated with changed events.
+ InstrumentationRegistry.getInstrumentation().waitForIdleSync();
+
+ // Multiple all-day events should be collapsed.
+ onView(withId(R.id.expand_collapse_icon)).check(matches(isDisplayed()));
+ onView(withText(EVENT_TITLE)).check(matches(not(isDisplayed())));
+ }
+ }
+
+ @Test
+ public void multipleAllDayEvents_expands() {
+ mTestEventRows.add(buildTestRowAllDay(EVENT_TITLE));
+ mTestEventRows.add(buildTestRowAllDay("Another all day event"));
+
+ try (ActivityScenario<CarCalendarActivity> ignored =
+ ActivityScenario.launch(CarCalendarActivity.class)) {
+ waitForEventsChange();
+
+ // Wait for the UI to be updated with changed events.
+ InstrumentationRegistry.getInstrumentation().waitForIdleSync();
+
+ // Multiple all-day events should be collapsed.
+ onView(withId(R.id.expand_collapse_icon)).perform(click());
+ InstrumentationRegistry.getInstrumentation().waitForIdleSync();
+ onView(withText(EVENT_TITLE)).check(matches(isDisplayed()));
+ }
+ }
+
+ private void waitForEventsChange() {
+ try {
+ mEventChangesLatch.await(10, TimeUnit.SECONDS);
+ } catch (InterruptedException e) {
+ throw new RuntimeException(e);
+ }
+ }
+
+ private class TestCalendarContentProvider extends MockContentProvider {
+ TestCalendarContentProvider(Context context) {
+ super(context);
+ }
+
+ @Override
+ public Cursor query(
+ Uri uri,
+ String[] projection,
+ Bundle queryArgs,
+ CancellationSignal cancellationSignal) {
+ if (uri.toString().startsWith(CalendarContract.Instances.CONTENT_URI.toString())) {
+ MatrixCursor cursor =
+ new MatrixCursor(
+ new String[] {
+ CalendarContract.Instances.TITLE,
+ CalendarContract.Instances.ALL_DAY,
+ CalendarContract.Instances.BEGIN,
+ CalendarContract.Instances.END,
+ CalendarContract.Instances.DESCRIPTION,
+ CalendarContract.Instances.EVENT_LOCATION,
+ CalendarContract.Instances.SELF_ATTENDEE_STATUS,
+ CalendarContract.Instances.CALENDAR_COLOR,
+ CalendarContract.Instances.CALENDAR_DISPLAY_NAME,
+ });
+ for (Object[] row : mTestEventRows) {
+ cursor.addRow(row);
+ }
+ return cursor;
+ } else if (uri.equals(CalendarContract.Calendars.CONTENT_URI)) {
+ MatrixCursor cursor = new MatrixCursor(new String[] {" Test name"});
+ cursor.addRow(new String[] {"Test value"});
+ return cursor;
+ }
+ throw new IllegalStateException("Unexpected query uri " + uri);
+ }
+ }
+
+ private Object[] buildTestRowAllDay(String title) {
+ // All day events are stored in UTC time.
+ ZonedDateTime utcDayStartTime =
+ START_DATE_TIME.withZoneSameInstant(UTC_ZONE_ID).truncatedTo(ChronoUnit.DAYS);
+ return buildTestRow(utcDayStartTime, 24, title, true);
+ }
+
+ private static Object[] buildTestRow(
+ ZonedDateTime startDateTime, int eventDurationHours, String title, boolean allDay) {
+ return new Object[] {
+ title,
+ allDay ? 1 : 0,
+ startDateTime.toInstant().toEpochMilli(),
+ startDateTime.plusHours(eventDurationHours).toInstant().toEpochMilli(),
+ EVENT_DESCRIPTION,
+ EVENT_LOCATION,
+ EVENT_ATTENDEE_STATUS,
+ CALENDAR_COLOR,
+ CALENDAR_NAME
+ };
+ }
+}
diff --git a/tests/unit/Android.bp b/tests/unit/Android.bp
new file mode 100644
index 0000000..42c7ee0
--- /dev/null
+++ b/tests/unit/Android.bp
@@ -0,0 +1,37 @@
+// Copyright (C) 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.
+//
+
+android_test {
+ name: "CarCalendarUnitTests",
+ srcs: ["src/**/*.java"],
+ instrumentation_for: "CarCalendarApp",
+ optimize: {
+ enabled: false,
+ },
+ sdk_version: "system_current",
+ static_libs: [
+ "androidx.annotation_annotation",
+ "androidx.test.ext.junit",
+ "androidx.test.rules",
+ "androidx.test.runner",
+ "mockito-target",
+ "truth-prebuilt",
+ ],
+ libs: [
+ "android.test.base.stubs",
+ "android.test.mock.stubs",
+ "android.test.runner.stubs",
+ ],
+}
diff --git a/tests/unit/AndroidManifest.xml b/tests/unit/AndroidManifest.xml
new file mode 100644
index 0000000..08e66c1
--- /dev/null
+++ b/tests/unit/AndroidManifest.xml
@@ -0,0 +1,34 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+ ~ Copyright (C) 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.
+-->
+<manifest xmlns:android="http://schemas.android.com/apk/res/android"
+ package="com.android.car.calendar.tests.unit" >
+
+ <uses-sdk android:targetSdkVersion="28" android:minSdkVersion="23" />
+
+ <uses-permission android:name="android.permission.READ_CALENDAR" />
+ <uses-permission android:name="android.permission.CALL_PHONE" />
+
+ <instrumentation
+ android:name="androidx.test.runner.AndroidJUnitRunner"
+ android:label="Car Calendar Unit Tests"
+ android:targetPackage="com.android.car.calendar" />
+
+ <application android:label="CarCalendarUnitTests" >
+ <uses-library android:name="android.test.runner" />
+ </application>
+
+</manifest>
diff --git a/tests/unit/src/com/android/car/calendar/common/CalendarFormatterTest.java b/tests/unit/src/com/android/car/calendar/common/CalendarFormatterTest.java
new file mode 100644
index 0000000..132504e
--- /dev/null
+++ b/tests/unit/src/com/android/car/calendar/common/CalendarFormatterTest.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 static com.google.common.truth.Truth.assertThat;
+
+import android.content.Context;
+
+import androidx.test.platform.app.InstrumentationRegistry;
+
+import org.junit.Before;
+import org.junit.Test;
+
+import java.time.Clock;
+import java.time.LocalDateTime;
+import java.time.ZoneId;
+import java.time.ZonedDateTime;
+import java.util.Locale;
+
+public class CalendarFormatterTest {
+
+ private static final ZoneId BERLIN_ZONE_ID = ZoneId.of("Europe/Berlin");
+ private static final ZonedDateTime CURRENT_DATE_TIME =
+ LocalDateTime.of(2019, 12, 10, 10, 10, 10, 500500).atZone(BERLIN_ZONE_ID);
+ private static final Locale LOCALE = Locale.ENGLISH;
+ private CalendarFormatter mFormatter;
+
+ @Before
+ public void setUp() {
+ Context context = InstrumentationRegistry.getInstrumentation().getTargetContext();
+ Clock clock = Clock.fixed(CURRENT_DATE_TIME.toInstant(), BERLIN_ZONE_ID);
+ mFormatter = new CalendarFormatter(context, LOCALE, clock);
+ }
+
+ @Test
+ public void getDateText_today() {
+ String dateText = mFormatter.getDateText(CURRENT_DATE_TIME.toLocalDate());
+
+ assertThat(dateText).startsWith("Today");
+ assertThat(dateText).endsWith("Tue, Dec 10");
+ }
+
+ @Test
+ public void getDateText_tomorrow() {
+ String dateText = mFormatter.getDateText(CURRENT_DATE_TIME.plusDays(1).toLocalDate());
+
+ assertThat(dateText).startsWith("Tomorrow");
+ assertThat(dateText).endsWith("Wed, Dec 11");
+ }
+
+ @Test
+ public void getDateText_nextWeek_onlyShowsDate() {
+ String dateText = mFormatter.getDateText(CURRENT_DATE_TIME.plusDays(7).toLocalDate());
+
+ assertThat(dateText).isEqualTo("Tue, Dec 17");
+ }
+
+ @Test
+ public void getTimeRangeText_sameAmPm() {
+ String dateText =
+ mFormatter.getTimeRangeText(
+ CURRENT_DATE_TIME.toInstant(), CURRENT_DATE_TIME.plusHours(1).toInstant());
+
+ assertThat(dateText).isEqualTo("10:10 – 11:10 AM");
+ }
+
+ @Test
+ public void getTimeRangeText_differentAmPm() {
+ String dateText =
+ mFormatter.getTimeRangeText(
+ CURRENT_DATE_TIME.toInstant(), CURRENT_DATE_TIME.plusHours(3).toInstant());
+
+ assertThat(dateText).isEqualTo("10:10 AM – 1:10 PM");
+ }
+
+ @Test
+ public void getTimeRangeText_differentDays() {
+ String dateText =
+ mFormatter.getTimeRangeText(
+ CURRENT_DATE_TIME.toInstant(), CURRENT_DATE_TIME.plusDays(1).toInstant());
+
+ assertThat(dateText).isEqualTo("Dec 10, 10:10 AM – Dec 11, 10:10 AM");
+ }
+}
diff --git a/tests/unit/src/com/android/car/calendar/common/EventDescriptionsTest.java b/tests/unit/src/com/android/car/calendar/common/EventDescriptionsTest.java
new file mode 100644
index 0000000..358e9cf
--- /dev/null
+++ b/tests/unit/src/com/android/car/calendar/common/EventDescriptionsTest.java
@@ -0,0 +1,131 @@
+/*
+ * 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.truth.Truth.assertThat;
+
+import android.net.Uri;
+
+import com.google.common.collect.Iterables;
+
+import org.junit.Before;
+import org.junit.Test;
+
+import java.io.UnsupportedEncodingException;
+import java.util.List;
+import java.util.Locale;
+
+public class EventDescriptionsTest {
+
+ private static final String BASE_NUMBER = "30 303986300";
+ private static final String LOCAL_NUMBER = "0" + BASE_NUMBER;
+ private static final String INTERNATIONAL_NUMBER = "+49 " + BASE_NUMBER;
+ private static final String ACCESS = ",,12;3*45#";
+ private EventDescriptions mEventDescriptions;
+
+ @Before
+ public void setUp() {
+ mEventDescriptions = new EventDescriptions(Locale.GERMANY);
+ }
+
+ @Test
+ public void extractNumberAndPin_localNumber_resultIsLocal() {
+ List<Dialer.NumberAndAccess> numberAndAccesses =
+ mEventDescriptions.extractNumberAndPins(LOCAL_NUMBER);
+ assertThat(numberAndAccesses).isNotEmpty();
+ Dialer.NumberAndAccess numberAndAccess = Iterables.getFirst(numberAndAccesses, null);
+ assertThat(numberAndAccess.getNumber()).isEqualTo(INTERNATIONAL_NUMBER);
+ }
+
+ @Test
+ public void extractNumberAndPin_internationalNumber_resultIsLocal() {
+ List<Dialer.NumberAndAccess> numberAndAccesses =
+ mEventDescriptions.extractNumberAndPins(INTERNATIONAL_NUMBER);
+ assertThat(numberAndAccesses).isNotEmpty();
+ Dialer.NumberAndAccess numberAndAccess = Iterables.getFirst(numberAndAccesses, null);
+ assertThat(numberAndAccess.getNumber()).isEqualTo(INTERNATIONAL_NUMBER);
+ }
+
+ @Test
+ public void extractNumberAndPin_internationalNumberWithDifferentLocale_resultIsInternational() {
+ mEventDescriptions = new EventDescriptions(Locale.FRANCE);
+ List<Dialer.NumberAndAccess> numberAndAccesses =
+ mEventDescriptions.extractNumberAndPins(INTERNATIONAL_NUMBER);
+ assertThat(numberAndAccesses).isNotEmpty();
+ Dialer.NumberAndAccess numberAndAccess = Iterables.getFirst(numberAndAccesses, null);
+ assertThat(numberAndAccess.getNumber()).isEqualTo(INTERNATIONAL_NUMBER);
+ }
+
+ @Test
+ public void extractNumberAndPin_internationalNumberAndPin() {
+ String input = INTERNATIONAL_NUMBER + " PIN: " + ACCESS;
+ List<Dialer.NumberAndAccess> numberAndAccesses =
+ mEventDescriptions.extractNumberAndPins(input);
+ assertThat(numberAndAccesses).isNotEmpty();
+ Dialer.NumberAndAccess numberAndAccess = Iterables.getFirst(numberAndAccesses, null);
+ assertThat(numberAndAccess.getNumber()).isEqualTo(INTERNATIONAL_NUMBER);
+ assertThat(numberAndAccess.getAccess()).isEqualTo(ACCESS);
+ }
+
+ @Test
+ public void extractNumberAndPin_internationalNumberAndCode() {
+ String input = INTERNATIONAL_NUMBER + " with access code " + ACCESS;
+ List<Dialer.NumberAndAccess> numberAndAccesses =
+ mEventDescriptions.extractNumberAndPins(input);
+ assertThat(numberAndAccesses).isNotEmpty();
+ Dialer.NumberAndAccess numberAndAccess = Iterables.getFirst(numberAndAccesses, null);
+ assertThat(numberAndAccess.getNumber()).isEqualTo(INTERNATIONAL_NUMBER);
+ assertThat(numberAndAccess.getAccess()).isEqualTo(ACCESS);
+ }
+
+ @Test
+ public void extractNumberAndPin_multipleNumbers() {
+ String input =
+ INTERNATIONAL_NUMBER
+ + " PIN: "
+ + ACCESS
+ + "\n an invalid one is "
+ + BASE_NUMBER
+ + " but a local one is "
+ + LOCAL_NUMBER;
+ List<Dialer.NumberAndAccess> numberAndAccesses =
+ mEventDescriptions.extractNumberAndPins(input);
+
+ // The local number is valid but repeated so only included once.
+ assertThat(numberAndAccesses).hasSize(1);
+ }
+
+ @Test
+ public void extractNumberAndPin_encodedTelFormat() throws UnsupportedEncodingException {
+ String encoded = Uri.encode(INTERNATIONAL_NUMBER + ACCESS);
+ String input = "blah blah <tel:" + encoded + "> blah blah";
+ List<Dialer.NumberAndAccess> numberAndAccesses =
+ mEventDescriptions.extractNumberAndPins(input);
+ assertThat(numberAndAccesses).hasSize(1);
+ Dialer.NumberAndAccess numberAndAccess = Iterables.getFirst(numberAndAccesses, null);
+ assertThat(numberAndAccess.getNumber()).isEqualTo(INTERNATIONAL_NUMBER);
+ assertThat(numberAndAccess.getAccess()).isEqualTo(ACCESS);
+ }
+
+ @Test
+ public void extractNumberAndPin_smallNumber_returnsNull() throws UnsupportedEncodingException {
+ String input = "blah blah 345 - blah blah";
+ List<Dialer.NumberAndAccess> numberAndAccesses =
+ mEventDescriptions.extractNumberAndPins(input);
+ assertThat(numberAndAccesses).isEmpty();
+ }
+}
diff --git a/tests/unit/src/com/android/car/calendar/common/EventLocationsTest.java b/tests/unit/src/com/android/car/calendar/common/EventLocationsTest.java
new file mode 100644
index 0000000..23f833a
--- /dev/null
+++ b/tests/unit/src/com/android/car/calendar/common/EventLocationsTest.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.common;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import org.junit.Before;
+import org.junit.Test;
+
+public class EventLocationsTest {
+
+ private static final String BASE_NUMBER = "30 303986300";
+
+ private EventLocations mEventLocations;
+
+ @Before
+ public void setUp() {
+ mEventLocations = new EventLocations();
+ }
+
+ @Test
+ public void isValidLocation_meetingRooms_isFalse() {
+ assertThat(mEventLocations.isValidLocation("MUC-ARP-6Z3-Radln (5) [GVC]")).isFalse();
+ assertThat(mEventLocations.isValidLocation("SFO-SPE-3-Anchor Brewing Co. (1) [GVC]"))
+ .isFalse();
+ assertThat(mEventLocations.isValidLocation("SFO-SPE-3-Speakeasy Ales & Lagers (10) [GVC]"))
+ .isFalse();
+ assertThat(
+ mEventLocations.isValidLocation(
+ "MTV-900-1-Good Charlotte (13) [GVC, No External Guests]"))
+ .isFalse();
+ assertThat(
+ mEventLocations.isValidLocation(
+ "MTV-900-2-Panic! at the Disco (5) [GVC, Jamboard]"))
+ .isFalse();
+ assertThat(mEventLocations.isValidLocation("US-MTV-900-1-1F2 (collaboration area)"))
+ .isFalse();
+ }
+
+ @Test
+ public void isValidLocation_notMeetingRooms_isTrue() {
+ assertThat(mEventLocations.isValidLocation("My place")).isTrue();
+ assertThat(mEventLocations.isValidLocation("At JDP-1974-09")).isTrue();
+ assertThat(mEventLocations.isValidLocation("178.3454, 234.345")).isTrue();
+ }
+}
diff --git a/tests/unit/src/com/android/car/calendar/common/EventsLiveDataTest.java b/tests/unit/src/com/android/car/calendar/common/EventsLiveDataTest.java
new file mode 100644
index 0000000..ff00e8d
--- /dev/null
+++ b/tests/unit/src/com/android/car/calendar/common/EventsLiveDataTest.java
@@ -0,0 +1,609 @@
+/*
+ * 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.truth.Truth.assertThat;
+
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+import static java.time.temporal.ChronoUnit.HOURS;
+
+import android.Manifest;
+import android.content.Context;
+import android.database.ContentObserver;
+import android.database.Cursor;
+import android.database.MatrixCursor;
+import android.net.Uri;
+import android.os.Bundle;
+import android.os.CancellationSignal;
+import android.os.Handler;
+import android.os.HandlerThread;
+import android.os.Message;
+import android.os.Process;
+import android.os.SystemClock;
+import android.provider.CalendarContract;
+import android.test.mock.MockContentProvider;
+import android.test.mock.MockContentResolver;
+
+import androidx.lifecycle.Observer;
+import androidx.test.annotation.UiThreadTest;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import androidx.test.platform.app.InstrumentationRegistry;
+import androidx.test.rule.GrantPermissionRule;
+
+import com.google.common.collect.ImmutableList;
+
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+import java.time.Clock;
+import java.time.Duration;
+import java.time.Instant;
+import java.time.LocalDateTime;
+import java.time.ZoneId;
+import java.time.ZonedDateTime;
+import java.time.temporal.ChronoField;
+import java.time.temporal.ChronoUnit;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+
+@RunWith(AndroidJUnit4.class)
+public class EventsLiveDataTest {
+ private static final ZoneId BERLIN_ZONE_ID = ZoneId.of("Europe/Berlin");
+ private static final ZonedDateTime CURRENT_DATE_TIME =
+ LocalDateTime.of(2019, 12, 10, 10, 10, 10, 500500).atZone(BERLIN_ZONE_ID);
+ private static final Dialer.NumberAndAccess EVENT_NUMBER_PIN =
+ new Dialer.NumberAndAccess("the number", "the pin");
+ private static final String EVENT_TITLE = "the title";
+ private static final boolean EVENT_ALL_DAY = false;
+ private static final String EVENT_LOCATION = "the location";
+ private static final String EVENT_DESCRIPTION = "the description";
+ private static final String CALENDAR_NAME = "the calendar name";
+ private static final int CALENDAR_COLOR = 0xCAFEBABE;
+ private static final int EVENT_ATTENDEE_STATUS =
+ CalendarContract.Attendees.ATTENDEE_STATUS_ACCEPTED;
+
+ @Rule
+ public final GrantPermissionRule permissionRule =
+ GrantPermissionRule.grant(Manifest.permission.READ_CALENDAR);
+
+ private EventsLiveData mEventsLiveData;
+ private TestContentProvider mTestContentProvider;
+ private TestHandler mTestHandler;
+ private TestClock mTestClock;
+
+ @Before
+ public void setUp() {
+ mTestClock = new TestClock(BERLIN_ZONE_ID);
+ mTestClock.setTime(CURRENT_DATE_TIME);
+ Context context = InstrumentationRegistry.getInstrumentation().getTargetContext();
+
+ // Create a fake result for the calendar content provider.
+ MockContentResolver mockContentResolver = new MockContentResolver(context);
+
+ mTestContentProvider = new TestContentProvider(context);
+ mockContentResolver.addProvider(CalendarContract.AUTHORITY, mTestContentProvider);
+
+ EventDescriptions mockEventDescriptions = mock(EventDescriptions.class);
+ when(mockEventDescriptions.extractNumberAndPins(any()))
+ .thenReturn(ImmutableList.of(EVENT_NUMBER_PIN));
+
+ EventLocations mockEventLocations = mock(EventLocations.class);
+ when(mockEventLocations.isValidLocation(anyString())).thenReturn(true);
+ mTestHandler = TestHandler.create();
+ mEventsLiveData =
+ new EventsLiveData(
+ mTestClock,
+ mTestHandler,
+ mockContentResolver,
+ mockEventDescriptions,
+ mockEventLocations);
+ }
+
+ @After
+ public void tearDown() {
+ if (mTestHandler != null) {
+ mTestHandler.stop();
+ }
+ }
+
+ @Test
+ public void noObserver_noQueryMade() {
+ // No query should be made because there are no observers.
+ assertThat(mTestContentProvider.mTestEventCursor).isNull();
+ }
+
+ @Test
+ @UiThreadTest
+ public void addObserver_queryMade() throws InterruptedException {
+ // Expect onChanged to be called for when we start to observe and when the data is read.
+ CountDownLatch latch = new CountDownLatch(2);
+ mEventsLiveData.observeForever((value) -> latch.countDown());
+
+ // Wait for the data to be read on the background thread.
+ latch.await(5, TimeUnit.SECONDS);
+
+ assertThat(mTestContentProvider.mTestEventCursor).isNotNull();
+ }
+
+ @Test
+ @UiThreadTest
+ public void addObserver_contentObserved() throws InterruptedException {
+ // Expect onChanged to be called for when we start to observe and when the data is read.
+ CountDownLatch latch = new CountDownLatch(2);
+ mEventsLiveData.observeForever((value) -> latch.countDown());
+
+ // Wait for the data to be read on the background thread.
+ latch.await(5, TimeUnit.SECONDS);
+
+ assertThat(mTestContentProvider.mTestEventCursor.mLastContentObserver).isNotNull();
+ }
+
+ @Test
+ @UiThreadTest
+ public void removeObserver_contentNotObserved() throws InterruptedException {
+ // Expect onChanged when we observe, when the data is read, and when we stop observing.
+ final CountDownLatch latch = new CountDownLatch(2);
+ Observer<ImmutableList<Event>> observer = (value) -> latch.countDown();
+ mEventsLiveData.observeForever(observer);
+
+ // Wait for the data to be read on the background thread.
+ latch.await(5, TimeUnit.SECONDS);
+
+ final CountDownLatch latch2 = new CountDownLatch(1);
+ mEventsLiveData.removeObserver(observer);
+
+ // Wait for the observer to be unregistered on the background thread.
+ latch2.await(5, TimeUnit.SECONDS);
+
+ assertThat(mTestContentProvider.mTestEventCursor.mLastContentObserver).isNull();
+ }
+
+ @Test
+ public void addObserver_oneEventResult() throws InterruptedException {
+
+ mTestContentProvider.addRow(buildTestRowWithDuration(CURRENT_DATE_TIME, 1));
+
+ // Expect onChanged to be called for when we start to observe and when the data is read.
+ CountDownLatch latch = new CountDownLatch(2);
+
+ // Must add observer on main thread.
+ runOnMain(() -> mEventsLiveData.observeForever((value) -> latch.countDown()));
+
+ // Wait for the data to be read on the background thread.
+ latch.await(5, TimeUnit.SECONDS);
+
+ ImmutableList<Event> events = mEventsLiveData.getValue();
+ assertThat(events).isNotNull();
+ assertThat(events).hasSize(1);
+ Event event = events.get(0);
+
+ long eventStartMillis = addHoursAndTruncate(CURRENT_DATE_TIME, 0);
+ long eventEndMillis = addHoursAndTruncate(CURRENT_DATE_TIME, 1);
+
+ assertThat(event.getTitle()).isEqualTo(EVENT_TITLE);
+ assertThat(event.getCalendarDetails().getColor()).isEqualTo(CALENDAR_COLOR);
+ assertThat(event.getLocation()).isEqualTo(EVENT_LOCATION);
+ assertThat(event.getStartInstant().toEpochMilli()).isEqualTo(eventStartMillis);
+ assertThat(event.getEndInstant().toEpochMilli()).isEqualTo(eventEndMillis);
+ assertThat(event.getStatus()).isEqualTo(Event.Status.ACCEPTED);
+ assertThat(event.getNumberAndAccess()).isEqualTo(EVENT_NUMBER_PIN);
+ }
+
+ @Test
+ public void changeCursorData_onChangedCalled() throws InterruptedException {
+ // Expect onChanged to be called for when we start to observe and when the data is read.
+ CountDownLatch initializeCountdownLatch = new CountDownLatch(2);
+
+ // Expect the same init callbacks as above but with an extra when the data is updated.
+ CountDownLatch changeCountdownLatch = new CountDownLatch(3);
+
+ // Must add observer on main thread.
+ runOnMain(
+ () ->
+ mEventsLiveData.observeForever(
+ // Count down both latches when data is changed.
+ (value) -> {
+ initializeCountdownLatch.countDown();
+ changeCountdownLatch.countDown();
+ }));
+
+ // Wait for the data to be read on the background thread.
+ initializeCountdownLatch.await(5, TimeUnit.SECONDS);
+
+ // Signal that the content has changed.
+ mTestContentProvider.mTestEventCursor.signalDataChanged();
+
+ // Wait for the changed data to be read on the background thread.
+ changeCountdownLatch.await(5, TimeUnit.SECONDS);
+ }
+
+ private void runOnMain(Runnable runnable) {
+ InstrumentationRegistry.getInstrumentation().runOnMainSync(runnable);
+ }
+
+ @Test
+ public void addObserver_updateScheduled() throws InterruptedException {
+ mTestHandler.setExpectedMessageCount(2);
+
+ // Must add observer on main thread.
+ runOnMain(
+ () ->
+ mEventsLiveData.observeForever(
+ (value) -> {
+ /* Do nothing */
+ }));
+
+ mTestHandler.awaitExpectedMessages(5);
+
+ // Show that a message was scheduled for the future.
+ assertThat(mTestHandler.mLastUptimeMillis).isAtLeast(SystemClock.uptimeMillis());
+ }
+
+ @Test
+ public void noCalendars_valueNull() throws InterruptedException {
+ mTestContentProvider.mAddFakeCalendar = false;
+
+ // Expect onChanged to be called for when we start to observe and when the data is read.
+ CountDownLatch latch = new CountDownLatch(2);
+ runOnMain(() -> mEventsLiveData.observeForever((value) -> latch.countDown()));
+
+ // Wait for the data to be read on the background thread.
+ latch.await(5, TimeUnit.SECONDS);
+
+ assertThat(mEventsLiveData.getValue()).isNull();
+ }
+
+ @Test
+ @UiThreadTest
+ public void noCalendars_contentObserved() throws InterruptedException {
+ mTestContentProvider.mAddFakeCalendar = false;
+
+ // Expect onChanged to be called for when we start to observe and when the data is read.
+ CountDownLatch latch = new CountDownLatch(2);
+ mEventsLiveData.observeForever((value) -> latch.countDown());
+
+ // Wait for the data to be read on the background thread.
+ latch.await(5, TimeUnit.SECONDS);
+
+ assertThat(mTestContentProvider.mTestEventCursor.mLastContentObserver).isNotNull();
+ }
+
+ @Test
+ public void multiDayEvent_createsMultipleEvents() throws InterruptedException {
+ // Replace the default event with one that lasts 24 hours.
+ mTestContentProvider.addRow(buildTestRowWithDuration(CURRENT_DATE_TIME, 24));
+
+ // Expect onChanged to be called for when we start to observe and when the data is read.
+ CountDownLatch latch = new CountDownLatch(2);
+
+ runOnMain(() -> mEventsLiveData.observeForever((value) -> latch.countDown()));
+
+ // Wait for the data to be read on the background thread.
+ latch.await(5, TimeUnit.SECONDS);
+
+ // Expect an event for the 2 parts of the split event instance.
+ assertThat(mEventsLiveData.getValue()).hasSize(2);
+ }
+
+ @Test
+ public void multiDayEvent_keepsOriginalTimes() throws InterruptedException {
+ // Replace the default event with one that lasts 24 hours.
+ int hours = 48;
+ mTestContentProvider.addRow(buildTestRowWithDuration(CURRENT_DATE_TIME, hours));
+
+ // Expect onChanged to be called for when we start to observe and when the data is read.
+ CountDownLatch latch = new CountDownLatch(2);
+
+ runOnMain(() -> mEventsLiveData.observeForever((value) -> latch.countDown()));
+
+ // Wait for the data to be read on the background thread.
+ latch.await(5, TimeUnit.SECONDS);
+
+ Event middlePartEvent = mEventsLiveData.getValue().get(1);
+
+ // The start and end times should remain the original times.
+ ZonedDateTime expectedStartTime = CURRENT_DATE_TIME.truncatedTo(HOURS);
+ assertThat(middlePartEvent.getStartInstant()).isEqualTo(expectedStartTime.toInstant());
+ ZonedDateTime expectedEndTime = expectedStartTime.plus(hours, HOURS);
+ assertThat(middlePartEvent.getEndInstant()).isEqualTo(expectedEndTime.toInstant());
+ }
+
+ @Test
+ public void multipleEvents_resultsSortedStart() throws InterruptedException {
+ // Replace the default event with two that are out of time order.
+ ZonedDateTime twoHoursAfterCurrentTime = CURRENT_DATE_TIME.plus(Duration.ofHours(2));
+ mTestContentProvider.addRow(buildTestRowWithDuration(twoHoursAfterCurrentTime, 1));
+ mTestContentProvider.addRow(buildTestRowWithDuration(CURRENT_DATE_TIME, 1));
+
+ // Expect onChanged to be called for when we start to observe and when the data is read.
+ CountDownLatch latch = new CountDownLatch(2);
+
+ runOnMain(() -> mEventsLiveData.observeForever((value) -> latch.countDown()));
+
+ // Wait for the data to be read on the background thread.
+ latch.await(5, TimeUnit.SECONDS);
+
+ ImmutableList<Event> events = mEventsLiveData.getValue();
+
+ assertThat(events.get(0).getStartInstant().toEpochMilli())
+ .isEqualTo(addHoursAndTruncate(CURRENT_DATE_TIME, 0));
+ assertThat(events.get(1).getStartInstant().toEpochMilli())
+ .isEqualTo(addHoursAndTruncate(CURRENT_DATE_TIME, 2));
+ }
+
+ @Test
+ public void multipleEvents_resultsSortedTitle() throws InterruptedException {
+ // Replace the default event with two that are out of time order.
+ mTestContentProvider.addRow(buildTestRowWithTitle(CURRENT_DATE_TIME, "Title B"));
+ mTestContentProvider.addRow(buildTestRowWithTitle(CURRENT_DATE_TIME, "Title A"));
+ mTestContentProvider.addRow(buildTestRowWithTitle(CURRENT_DATE_TIME, "Title C"));
+
+ // Expect onChanged to be called for when we start to observe and when the data is read.
+ CountDownLatch latch = new CountDownLatch(2);
+
+ runOnMain(() -> mEventsLiveData.observeForever((value) -> latch.countDown()));
+
+ // Wait for the data to be read on the background thread.
+ latch.await(5, TimeUnit.SECONDS);
+
+ ImmutableList<Event> events = mEventsLiveData.getValue();
+
+ assertThat(events.get(0).getTitle()).isEqualTo("Title A");
+ assertThat(events.get(1).getTitle()).isEqualTo("Title B");
+ assertThat(events.get(2).getTitle()).isEqualTo("Title C");
+ }
+
+ @Test
+ public void allDayEvent_timesSetToLocal() throws InterruptedException {
+ // All-day events always start at UTC midnight.
+ ZonedDateTime utcMidnightStart =
+ CURRENT_DATE_TIME.withZoneSameLocal(ZoneId.of("UTC")).truncatedTo(ChronoUnit.DAYS);
+ mTestContentProvider.addRow(buildTestRowAllDay(utcMidnightStart));
+
+ // Expect onChanged to be called for when we start to observe and when the data is read.
+ CountDownLatch latch = new CountDownLatch(2);
+
+ runOnMain(() -> mEventsLiveData.observeForever((value) -> latch.countDown()));
+
+ // Wait for the data to be read on the background thread.
+ latch.await(5, TimeUnit.SECONDS);
+
+ ImmutableList<Event> events = mEventsLiveData.getValue();
+
+ Instant localMidnightStart = CURRENT_DATE_TIME.truncatedTo(ChronoUnit.DAYS).toInstant();
+ assertThat(events.get(0).getStartInstant()).isEqualTo(localMidnightStart);
+ }
+
+ @Test
+ public void allDayEvent_queryCoversLocalDayStart() throws InterruptedException {
+ // All-day events always start at UTC midnight.
+ ZonedDateTime utcMidnightStart =
+ CURRENT_DATE_TIME.withZoneSameLocal(ZoneId.of("UTC")).truncatedTo(ChronoUnit.DAYS);
+ mTestContentProvider.addRow(buildTestRowAllDay(utcMidnightStart));
+
+ // Set the time to 23:XX in the BERLIN_ZONE_ID which will be after the event end time.
+ mTestClock.setTime(CURRENT_DATE_TIME.with(ChronoField.HOUR_OF_DAY, 23));
+
+ // Expect onChanged to be called for when we start to observe and when the data is read.
+ CountDownLatch latch = new CountDownLatch(2);
+
+ runOnMain(() -> mEventsLiveData.observeForever((value) -> latch.countDown()));
+
+ // Wait for the data to be read on the background thread.
+ latch.await(5, TimeUnit.SECONDS);
+
+ // Show that the event is included even though its end time is before the current time.
+ assertThat(mEventsLiveData.getValue()).isNotEmpty();
+ }
+
+ private static class TestContentProvider extends MockContentProvider {
+ TestEventCursor mTestEventCursor;
+ boolean mAddFakeCalendar = true;
+ List<Object[]> mEventRows = new ArrayList<>();
+
+ TestContentProvider(Context context) {
+ super(context);
+ }
+
+ private void addRow(Object[] row) {
+ mEventRows.add(row);
+ }
+
+ @Override
+ public Cursor query(
+ Uri uri,
+ String[] projection,
+ Bundle queryArgs,
+ CancellationSignal cancellationSignal) {
+ if (uri.toString().startsWith(CalendarContract.Instances.CONTENT_URI.toString())) {
+ mTestEventCursor = new TestEventCursor(uri);
+ for (Object[] row : mEventRows) {
+ mTestEventCursor.addRow(row);
+ }
+ return mTestEventCursor;
+ } else if (uri.equals(CalendarContract.Calendars.CONTENT_URI)) {
+ MatrixCursor calendarsCursor = new MatrixCursor(new String[] {" Test name"});
+ if (mAddFakeCalendar) {
+ calendarsCursor.addRow(new String[] {"Test value"});
+ }
+ return calendarsCursor;
+ }
+ throw new IllegalStateException("Unexpected query uri " + uri);
+ }
+
+ static class TestEventCursor extends MatrixCursor {
+ final Uri mUri;
+ ContentObserver mLastContentObserver;
+
+ TestEventCursor(Uri uri) {
+ super(
+ new String[] {
+ CalendarContract.Instances.TITLE,
+ CalendarContract.Instances.ALL_DAY,
+ CalendarContract.Instances.BEGIN,
+ CalendarContract.Instances.END,
+ CalendarContract.Instances.DESCRIPTION,
+ CalendarContract.Instances.EVENT_LOCATION,
+ CalendarContract.Instances.SELF_ATTENDEE_STATUS,
+ CalendarContract.Instances.CALENDAR_COLOR,
+ CalendarContract.Instances.CALENDAR_DISPLAY_NAME,
+ });
+ mUri = uri;
+ }
+
+ @Override
+ public void registerContentObserver(ContentObserver observer) {
+ super.registerContentObserver(observer);
+ mLastContentObserver = observer;
+ }
+
+ @Override
+ public void unregisterContentObserver(ContentObserver observer) {
+ super.unregisterContentObserver(observer);
+ mLastContentObserver = null;
+ }
+
+ void signalDataChanged() {
+ super.onChange(true);
+ }
+ }
+ }
+
+ private static class TestHandler extends Handler {
+ final HandlerThread mThread;
+ long mLastUptimeMillis;
+ CountDownLatch mCountDownLatch;
+
+ static TestHandler create() {
+ HandlerThread thread =
+ new HandlerThread(
+ EventsLiveDataTest.class.getSimpleName(),
+ Process.THREAD_PRIORITY_FOREGROUND);
+ thread.start();
+ return new TestHandler(thread);
+ }
+
+ TestHandler(HandlerThread thread) {
+ super(thread.getLooper());
+ mThread = thread;
+ }
+
+ void stop() {
+ mThread.quit();
+ }
+
+ void setExpectedMessageCount(int expectedMessageCount) {
+ mCountDownLatch = new CountDownLatch(expectedMessageCount);
+ }
+
+ void awaitExpectedMessages(int seconds) throws InterruptedException {
+ mCountDownLatch.await(seconds, TimeUnit.SECONDS);
+ }
+
+ @Override
+ public boolean sendMessageAtTime(Message msg, long uptimeMillis) {
+ mLastUptimeMillis = uptimeMillis;
+ if (mCountDownLatch != null) {
+ mCountDownLatch.countDown();
+ }
+ return super.sendMessageAtTime(msg, uptimeMillis);
+ }
+ }
+
+ // Similar to {@link android.os.SimpleClock} but without @hide and with mutable millis.
+ static class TestClock extends Clock {
+ private final ZoneId mZone;
+ private long mTimeMs;
+
+ TestClock(ZoneId zone) {
+ mZone = zone;
+ }
+
+ void setTime(ZonedDateTime time) {
+ mTimeMs = time.toInstant().toEpochMilli();
+ }
+
+ @Override
+ public ZoneId getZone() {
+ return mZone;
+ }
+
+ @Override
+ public Clock withZone(ZoneId zone) {
+ return new TestClock(zone) {
+ @Override
+ public long millis() {
+ return TestClock.this.millis();
+ }
+ };
+ }
+
+ @Override
+ public long millis() {
+ return mTimeMs;
+ }
+
+ @Override
+ public Instant instant() {
+ return Instant.ofEpochMilli(millis());
+ }
+ }
+
+ static long addHoursAndTruncate(ZonedDateTime dateTime, int hours) {
+ return dateTime.truncatedTo(HOURS)
+ .plus(Duration.ofHours(hours))
+ .toInstant()
+ .toEpochMilli();
+ }
+
+ static Object[] buildTestRowWithDuration(ZonedDateTime startDateTime, int eventDurationHours) {
+ return buildTestRowWithDuration(
+ startDateTime, eventDurationHours, EVENT_TITLE, EVENT_ALL_DAY);
+ }
+
+ static Object[] buildTestRowAllDay(ZonedDateTime startDateTime) {
+ return buildTestRowWithDuration(startDateTime, 24, EVENT_TITLE, true);
+ }
+
+ static Object[] buildTestRowWithTitle(ZonedDateTime startDateTime, String title) {
+ return buildTestRowWithDuration(startDateTime, 1, title, EVENT_ALL_DAY);
+ }
+
+ static Object[] buildTestRowWithDuration(
+ ZonedDateTime currentDateTime, int eventDurationHours, String title, boolean allDay) {
+ return new Object[] {
+ title,
+ allDay ? 1 : 0,
+ addHoursAndTruncate(currentDateTime, 0),
+ addHoursAndTruncate(currentDateTime, eventDurationHours),
+ EVENT_DESCRIPTION,
+ EVENT_LOCATION,
+ EVENT_ATTENDEE_STATUS,
+ CALENDAR_COLOR,
+ CALENDAR_NAME
+ };
+ }
+}