diff options
42 files changed, 3922 insertions, 0 deletions
diff --git a/Android.bp b/Android.bp new file mode 100644 index 0000000..e79e37b --- /dev/null +++ b/Android.bp @@ -0,0 +1,37 @@ +// Copyright (C) 2020 The Android Open Source Project +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +android_app { + name: "CarCalendarApp", + srcs: ["src/**/*.java"], + resource_dirs: ["res"], + platform_apis: true, + optimize: { + enabled: false, + }, + dex_preopt: { + enabled: false, + }, + aaptflags: ["--auto-add-overlay"], + static_libs: [ + "car-ui-lib", + "androidx.lifecycle_lifecycle-extensions", + "androidx.appcompat_appcompat", + "androidx.lifecycle_lifecycle-livedata", + "androidx.lifecycle_lifecycle-viewmodel", + "guava", + ], + libs: ["android.car"], +} diff --git a/AndroidManifest.xml b/AndroidManifest.xml new file mode 100644 index 0000000..4921dae --- /dev/null +++ b/AndroidManifest.xml @@ -0,0 +1,55 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + ~ Copyright (C) 2020 The Android Open Source Project + ~ + ~ Licensed under the Apache License, Version 2.0 (the "License"); + ~ you may not use this file except in compliance with the License. + ~ You may obtain a copy of the License at + ~ + ~ http://www.apache.org/licenses/LICENSE-2.0 + ~ + ~ Unless required by applicable law or agreed to in writing, software + ~ distributed under the License is distributed on an "AS IS" BASIS, + ~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + ~ See the License for the specific language governing permissions and + ~ limitations under the License. +--> +<manifest xmlns:android="http://schemas.android.com/apk/res/android" + xmlns:tools="http://schemas.android.com/tools" + package="com.android.car.calendar"> + + <uses-sdk + android:minSdkVersion="28" + android:targetSdkVersion="29"/> + + <uses-permission android:name="android.permission.READ_CALENDAR" /> + <uses-permission android:name="android.permission.CALL_PHONE" /> + + <application + android:allowBackup="true" + android:icon="@drawable/ic_calendar_sync" + android:label="@string/app_name" + android:theme="@style/Theme.CarUi" + android:supportsRtl="true"> + + <activity android:name=".CarCalendarActivity" + android:launchMode="singleTask"> + <intent-filter> + <action android:name="android.intent.action.MAIN"/> + <category android:name="android.intent.category.DEFAULT"/> + <category android:name="android.intent.category.APP_CALENDAR"/> + <category android:name="android.intent.category.LAUNCHER"/> + </intent-filter> + </activity> + + <!-- Work around b/113294940. --> + <provider + android:name="androidx.lifecycle.ProcessLifecycleOwnerInitializer" + tools:replace="android:authorities" + android:authorities="${applicationId}.lifecycle-tests" + android:exported="false" + android:multiprocess="true" /> + + </application> + +</manifest> @@ -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 + }; + } +} |