Add CarCalendarApp am: a66655fd37

Original change: https://googleplex-android-review.googlesource.com/c/platform/packages/apps/Car/Calendar/+/12098146

Change-Id: I4ef78859f4cf098b762364debb54d18299f2c1cc
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
+        };
+    }
+}