diff options
56 files changed, 5136 insertions, 0 deletions
diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..bdfa81c --- /dev/null +++ b/.gitignore @@ -0,0 +1,22 @@ +# Local configuration +local.properties +gradle-wrapper.properties + +# Gradle +.gradle/ +build/ +gradle-app.setting +.gradletasknamecache + +# IntelliJ +.idea/ +*.iml + +# Python +*.pyc + +# Android studio's layout inspector captures +captures/ + +# A file created when launching android emulators +read-snapshot.txt diff --git a/car-qc-lib/Android.bp b/car-qc-lib/Android.bp new file mode 100644 index 0000000..c12fd28 --- /dev/null +++ b/car-qc-lib/Android.bp @@ -0,0 +1,31 @@ +// +// Copyright (C) 2021 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 { + default_applicable_licenses: ["Android-Apache-2.0"], +} + +android_library { + name: "car-qc-lib", + platform_apis: true, + srcs: ["src/**/*.java"], + optimize: { + enabled: false, + }, + static_libs: [ + "androidx.annotation_annotation", + "car-ui-lib" + ], +} diff --git a/car-qc-lib/AndroidManifest.xml b/car-qc-lib/AndroidManifest.xml new file mode 100644 index 0000000..166d9c0 --- /dev/null +++ b/car-qc-lib/AndroidManifest.xml @@ -0,0 +1,19 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + ~ Copyright (C) 2021 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.qc"> +</manifest> diff --git a/car-qc-lib/OWNERS b/car-qc-lib/OWNERS new file mode 100644 index 0000000..7f8081c --- /dev/null +++ b/car-qc-lib/OWNERS @@ -0,0 +1,8 @@ +# People who can approve changes for submission. + +# Primary +alexstetson@google.com + +# Secondary (only if people in Primary are unreachable) +hseog@google.com +nehah@google.com diff --git a/car-qc-lib/PREUPLOAD.cfg b/car-qc-lib/PREUPLOAD.cfg new file mode 100644 index 0000000..38f9800 --- /dev/null +++ b/car-qc-lib/PREUPLOAD.cfg @@ -0,0 +1,7 @@ +[Hook Scripts] +checkstyle_hook = ${REPO_ROOT}/prebuilts/checkstyle/checkstyle.py --sha ${PREUPLOAD_COMMIT} +ktlint_hook = ${REPO_ROOT}/prebuilts/ktlint/ktlint.py -f ${PREUPLOAD_FILES} + +[Builtin Hooks] +commit_msg_changeid_field = true +commit_msg_test_field = true diff --git a/car-qc-lib/res/color/qc_toggle_background_color.xml b/car-qc-lib/res/color/qc_toggle_background_color.xml new file mode 100644 index 0000000..15253ad --- /dev/null +++ b/car-qc-lib/res/color/qc_toggle_background_color.xml @@ -0,0 +1,27 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + ~ Copyright (C) 2021 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. + --> +<selector xmlns:android="http://schemas.android.com/apk/res/android"> + <item android:state_checked="false" android:state_enabled="false" + android:alpha="?android:attr/disabledAlpha" + android:color="@color/qc_toggle_off_background_color"/> + <item android:state_checked="false" + android:color="@color/qc_toggle_off_background_color"/> + <item android:state_enabled="false" + android:alpha="?android:attr/disabledAlpha" + android:color="?android:attr/colorAccent"/> + <item android:color="?android:attr/colorAccent"/> +</selector> diff --git a/car-qc-lib/res/color/qc_toggle_icon_fill_color.xml b/car-qc-lib/res/color/qc_toggle_icon_fill_color.xml new file mode 100644 index 0000000..bdb5433 --- /dev/null +++ b/car-qc-lib/res/color/qc_toggle_icon_fill_color.xml @@ -0,0 +1,27 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + ~ Copyright (C) 2021 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. + --> +<selector xmlns:android="http://schemas.android.com/apk/res/android"> + <item android:state_checked="false" android:state_enabled="false" + android:alpha="?android:attr/disabledAlpha" + android:color="@android:color/white"/> + <item android:state_checked="false" + android:color="@android:color/white"/> + <item android:state_enabled="false" + android:alpha="?android:attr/disabledAlpha" + android:color="@android:color/black"/> + <item android:color="@android:color/black"/> +</selector> diff --git a/car-qc-lib/res/drawable/qc_row_action_divider.xml b/car-qc-lib/res/drawable/qc_row_action_divider.xml new file mode 100644 index 0000000..75ffd46 --- /dev/null +++ b/car-qc-lib/res/drawable/qc_row_action_divider.xml @@ -0,0 +1,21 @@ +<!-- + ~ Copyright (C) 2021 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. + --> + +<shape xmlns:android="http://schemas.android.com/apk/res/android"> + <size + android:height="0dp" + android:width="@dimen/qc_toggle_margin"/> +</shape> diff --git a/car-qc-lib/res/drawable/qc_seekbar_wrapper_background.xml b/car-qc-lib/res/drawable/qc_seekbar_wrapper_background.xml new file mode 100644 index 0000000..58b9c65 --- /dev/null +++ b/car-qc-lib/res/drawable/qc_seekbar_wrapper_background.xml @@ -0,0 +1,27 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + ~ Copyright (C) 2021 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. + --> +<selector xmlns:android="http://schemas.android.com/apk/res/android"> + <!-- Highlight the wrapper when it's focused but not selected. The wrapper is selected in + direct manipulation mode. --> + <item android:state_focused="true" android:state_selected="false"> + <shape android:shape="rectangle"> + <solid android:color="@color/car_ui_rotary_focus_fill_color"/> + <stroke android:width="@dimen/car_ui_rotary_focus_stroke_width" + android:color="@color/car_ui_rotary_focus_stroke_color"/> + </shape> + </item> +</selector>
\ No newline at end of file diff --git a/car-qc-lib/res/drawable/qc_toggle_background.xml b/car-qc-lib/res/drawable/qc_toggle_background.xml new file mode 100644 index 0000000..c139590 --- /dev/null +++ b/car-qc-lib/res/drawable/qc_toggle_background.xml @@ -0,0 +1,29 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + ~ Copyright (C) 2021 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. + --> +<layer-list xmlns:android="http://schemas.android.com/apk/res/android"> + <item android:id="@android:id/background" + android:width="@dimen/qc_toggle_size" + android:height="@dimen/qc_toggle_size"> + <shape android:shape="rectangle"> + <solid android:color="@color/qc_toggle_background_color" /> + <corners android:radius="@dimen/qc_toggle_background_radius" /> + </shape> + </item> + <item android:width="@dimen/qc_toggle_size" + android:height="@dimen/qc_toggle_size" + android:drawable="@drawable/qc_toggle_rotary_background"/> +</layer-list>
\ No newline at end of file diff --git a/car-qc-lib/res/drawable/qc_toggle_rotary_background.xml b/car-qc-lib/res/drawable/qc_toggle_rotary_background.xml new file mode 100644 index 0000000..406c44c --- /dev/null +++ b/car-qc-lib/res/drawable/qc_toggle_rotary_background.xml @@ -0,0 +1,34 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + ~ Copyright (C) 2021 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. + --> +<selector xmlns:android="http://schemas.android.com/apk/res/android"> + <item android:state_focused="true" android:state_pressed="true"> + <shape android:shape="rectangle"> + <solid android:color="@color/car_ui_rotary_focus_pressed_fill_secondary_color"/> + <stroke android:width="@dimen/car_ui_rotary_focus_pressed_stroke_width" + android:color="@color/car_ui_rotary_focus_pressed_stroke_secondary_color"/> + <corners android:radius="@dimen/qc_toggle_rotary_background_radius" /> + </shape> + </item> + <item android:state_focused="true"> + <shape android:shape="rectangle"> + <solid android:color="@color/car_ui_rotary_focus_fill_secondary_color"/> + <stroke android:width="@dimen/car_ui_rotary_focus_stroke_width" + android:color="@color/car_ui_rotary_focus_stroke_secondary_color"/> + <corners android:radius="@dimen/qc_toggle_rotary_background_radius" /> + </shape> + </item> +</selector>
\ No newline at end of file diff --git a/car-qc-lib/res/drawable/qc_toggle_unavailable_background.xml b/car-qc-lib/res/drawable/qc_toggle_unavailable_background.xml new file mode 100644 index 0000000..98cbded --- /dev/null +++ b/car-qc-lib/res/drawable/qc_toggle_unavailable_background.xml @@ -0,0 +1,31 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + ~ Copyright (C) 2021 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. + --> +<layer-list xmlns:android="http://schemas.android.com/apk/res/android"> + <item android:id="@android:id/background" + android:width="@dimen/qc_toggle_size" + android:height="@dimen/qc_toggle_size"> + <shape android:shape="rectangle"> + <solid android:color="@color/qc_toggle_unavailable_background_color" /> + <stroke android:color="@color/qc_toggle_unavailable_color" + android:width="@dimen/qc_toggle_unavailable_outline_width" /> + <corners android:radius="@dimen/qc_toggle_background_radius" /> + </shape> + </item> + <item android:width="@dimen/qc_toggle_size" + android:height="@dimen/qc_toggle_size" + android:drawable="@drawable/qc_toggle_rotary_background"/> +</layer-list> diff --git a/car-qc-lib/res/layout/qc_action_switch.xml b/car-qc-lib/res/layout/qc_action_switch.xml new file mode 100644 index 0000000..b443d8f --- /dev/null +++ b/car-qc-lib/res/layout/qc_action_switch.xml @@ -0,0 +1,30 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + ~ Copyright (C) 2021 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. + --> + +<FrameLayout + xmlns:android="http://schemas.android.com/apk/res/android" + android:id="@+id/qc_switch_frame" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:background="?android:attr/selectableItemBackground"> + <Switch + android:id="@android:id/switch_widget" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:clickable="false" + android:focusable="false" /> +</FrameLayout> diff --git a/car-qc-lib/res/layout/qc_action_toggle.xml b/car-qc-lib/res/layout/qc_action_toggle.xml new file mode 100644 index 0000000..301e0c4 --- /dev/null +++ b/car-qc-lib/res/layout/qc_action_toggle.xml @@ -0,0 +1,25 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + ~ Copyright (C) 2021 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. + --> +<com.android.car.ui.uxr.DrawableStateToggleButton + xmlns:android="http://schemas.android.com/apk/res/android" + android:id="@+id/qc_toggle_button" + android:background="@android:color/transparent" + android:defaultFocusHighlightEnabled="false" + android:minHeight="0dp" + android:minWidth="0dp" + android:layout_width="@dimen/qc_toggle_size" + android:layout_height="@dimen/qc_toggle_size"/>
\ No newline at end of file diff --git a/car-qc-lib/res/layout/qc_row_view.xml b/car-qc-lib/res/layout/qc_row_view.xml new file mode 100644 index 0000000..16309de --- /dev/null +++ b/car-qc-lib/res/layout/qc_row_view.xml @@ -0,0 +1,142 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + ~ Copyright (C) 2021 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. + --> + +<com.android.car.ui.uxr.DrawableStateConstraintLayout + xmlns:android="http://schemas.android.com/apk/res/android" + xmlns:app="http://schemas.android.com/apk/res-auto" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:layout_centerVertical="true" + android:clipToPadding="false" + android:minHeight="@dimen/qc_row_min_height" + android:paddingVertical="@dimen/qc_row_padding_vertical" + android:paddingEnd="@dimen/qc_row_padding_end" + android:paddingStart="@dimen/qc_row_padding_start"> + + <LinearLayout + android:id="@+id/qc_row_start_items" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_marginEnd="@dimen/qc_action_items_horizontal_margin" + android:orientation="horizontal" + android:divider="@drawable/qc_row_action_divider" + android:showDividers="middle" + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintTop_toTopOf="parent" + app:layout_constraintBottom_toBottomOf="parent" + app:layout_constraintEnd_toStartOf="@+id/qc_row_content" + app:layout_constraintHorizontal_chainStyle="spread_inside"/> + + <com.android.car.ui.uxr.DrawableStateConstraintLayout + android:id="@+id/qc_row_content" + android:layout_width="0dp" + android:layout_height="wrap_content" + android:background="?android:attr/selectableItemBackground" + app:layout_constraintStart_toEndOf="@+id/qc_row_start_items" + app:layout_constraintEnd_toStartOf="@+id/qc_row_end_items" + app:layout_constraintTop_toTopOf="parent" + app:layout_constraintBottom_toBottomOf="parent"> + + <com.android.car.ui.uxr.DrawableStateImageView + android:id="@+id/qc_icon" + android:layout_width="@dimen/qc_row_icon_size" + android:layout_height="@dimen/qc_row_icon_size" + android:layout_marginEnd="@dimen/qc_row_icon_margin_end" + android:scaleType="fitCenter" + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintEnd_toStartOf="@+id/barrier1" + app:layout_constraintTop_toTopOf="parent" + app:layout_constraintBottom_toTopOf="@+id/barrier2"/> + + <androidx.constraintlayout.widget.Barrier + android:id="@+id/barrier1" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + app:barrierDirection="end" + app:constraint_referenced_ids="qc_icon" + app:barrierAllowsGoneWidgets="false"/> + + <com.android.car.ui.uxr.DrawableStateTextView + android:id="@+id/qc_title" + android:layout_width="0dp" + android:layout_height="wrap_content" + android:layout_centerVertical="true" + android:singleLine="true" + android:textAppearance="@style/TextAppearance.QC.Title" + app:layout_constraintStart_toEndOf="@+id/barrier1" + app:layout_constraintTop_toTopOf="parent" + app:layout_constraintBottom_toTopOf="@+id/qc_summary" + app:layout_constraintEnd_toEndOf="parent" + app:layout_constraintVertical_chainStyle="packed"/> + + <com.android.car.ui.uxr.DrawableStateTextView + android:id="@+id/qc_summary" + android:layout_width="0dp" + android:layout_height="wrap_content" + android:layout_centerVertical="true" + android:textAppearance="@style/TextAppearance.QC.Subtitle" + app:layout_constraintStart_toEndOf="@+id/barrier1" + app:layout_constraintEnd_toEndOf="parent" + app:layout_constraintTop_toBottomOf="@+id/qc_title"/> + + <androidx.constraintlayout.widget.Barrier + android:id="@+id/barrier2" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + app:barrierDirection="bottom" + app:constraint_referenced_ids="qc_icon,qc_title,qc_summary" + app:barrierAllowsGoneWidgets="false"/> + + <androidx.preference.UnPressableLinearLayout + android:id="@+id/qc_seekbar_wrapper" + android:layout_width="0dp" + android:layout_height="wrap_content" + android:paddingVertical="@dimen/qc_seekbar_padding_vertical" + android:focusable="true" + android:background="@drawable/qc_seekbar_wrapper_background" + android:clipChildren="false" + android:clipToPadding="false" + android:layout_centerVertical="true" + android:orientation="vertical" + android:visibility="gone" + app:layout_constraintStart_toEndOf="@+id/barrier1" + app:layout_constraintEnd_toEndOf="parent" + app:layout_constraintTop_toBottomOf="@+id/barrier2" + app:layout_constraintBottom_toBottomOf="parent"> + <SeekBar + android:id="@+id/seekbar" + android:layout_width="match_parent" + android:layout_height="wrap_content" + style="@style/Widget.QC.SeekBar"/> + </androidx.preference.UnPressableLinearLayout> + + </com.android.car.ui.uxr.DrawableStateConstraintLayout> + + <LinearLayout + android:id="@+id/qc_row_end_items" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_marginStart="@dimen/qc_action_items_horizontal_margin" + android:orientation="horizontal" + android:divider="@drawable/qc_row_action_divider" + android:showDividers="middle" + app:layout_constraintStart_toEndOf="@+id/qc_row_content" + app:layout_constraintEnd_toEndOf="parent" + app:layout_constraintTop_toTopOf="parent" + app:layout_constraintBottom_toBottomOf="parent"/> + +</com.android.car.ui.uxr.DrawableStateConstraintLayout> diff --git a/car-qc-lib/res/layout/qc_tile_view.xml b/car-qc-lib/res/layout/qc_tile_view.xml new file mode 100644 index 0000000..7fb0884 --- /dev/null +++ b/car-qc-lib/res/layout/qc_tile_view.xml @@ -0,0 +1,41 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + ~ Copyright (C) 2021 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. + --> +<com.android.car.ui.uxr.DrawableStateLinearLayout + xmlns:android="http://schemas.android.com/apk/res/android" + android:id="@+id/qc_tile_wrapper" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:gravity="center" + android:orientation="vertical" + android:background="?android:attr/selectableItemBackground"> + <com.android.car.ui.uxr.DrawableStateToggleButton + android:id="@+id/qc_tile_toggle_button" + android:background="@android:color/transparent" + android:layout_width="@dimen/qc_toggle_size" + android:layout_height="@dimen/qc_toggle_size" + android:layout_marginTop="@dimen/qc_toggle_margin" + android:layout_marginBottom="@dimen/qc_toggle_margin" + android:layout_marginStart="@dimen/qc_toggle_margin" + android:layout_marginEnd="@dimen/qc_toggle_margin" + android:clickable="false" + android:focusable="false"/> + <com.android.car.ui.uxr.DrawableStateTextView + android:id="@android:id/summary" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:textAppearance="@style/TextAppearance.QC.Subtitle"/> +</com.android.car.ui.uxr.DrawableStateLinearLayout>
\ No newline at end of file diff --git a/car-qc-lib/res/values/colors.xml b/car-qc-lib/res/values/colors.xml new file mode 100644 index 0000000..e3fbd6f --- /dev/null +++ b/car-qc-lib/res/values/colors.xml @@ -0,0 +1,22 @@ +<!-- + ~ Copyright (C) 2021 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="qc_start_icon_color">@android:color/white</color> + <color name="qc_toggle_off_background_color">#626262</color> + <color name="qc_toggle_unavailable_background_color">@android:color/transparent</color> + <color name="qc_toggle_unavailable_color">#75FFFFFF</color> +</resources>
\ No newline at end of file diff --git a/car-qc-lib/res/values/dimens.xml b/car-qc-lib/res/values/dimens.xml new file mode 100644 index 0000000..778895f --- /dev/null +++ b/car-qc-lib/res/values/dimens.xml @@ -0,0 +1,35 @@ +<!-- + ~ Copyright (C) 2021 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="qc_row_padding_start">32dp</dimen> + <dimen name="qc_row_padding_end">32dp</dimen> + <dimen name="qc_row_min_height">76dp</dimen> + <dimen name="qc_row_padding_vertical">16dp</dimen> + <dimen name="qc_row_icon_size">44dp</dimen> + <dimen name="qc_row_icon_margin_end">32dp</dimen> + <dimen name="qc_row_content_margin">16dp</dimen> + + <dimen name="qc_action_items_horizontal_margin">32dp</dimen> + <dimen name="qc_toggle_size">72dp</dimen> + <dimen name="qc_toggle_margin">12dp</dimen> + <dimen name="qc_toggle_background_radius">16dp</dimen> + <dimen name="qc_toggle_rotary_background_radius">11dp</dimen> + <dimen name="qc_toggle_foreground_icon_inset">14dp</dimen> + <dimen name="qc_toggle_unavailable_outline_width">2dp</dimen> + + <dimen name="qc_seekbar_padding_vertical">16dp</dimen> +</resources>
\ No newline at end of file diff --git a/car-qc-lib/res/values/styles.xml b/car-qc-lib/res/values/styles.xml new file mode 100644 index 0000000..587b522 --- /dev/null +++ b/car-qc-lib/res/values/styles.xml @@ -0,0 +1,39 @@ +<!-- + ~ Copyright (C) 2021 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> + <style name="TextAppearance.QC" parent="android:TextAppearance.DeviceDefault"> + <item name="android:textColor">@color/car_ui_text_color_primary</item> + </style> + + <style name="TextAppearance.QC.Title"> + <item name="android:textSize">@dimen/car_ui_body1_size</item> + </style> + + <style name="TextAppearance.QC.Subtitle"> + <item name="android:textColor">@color/car_ui_text_color_secondary</item> + <item name="android:textSize">@dimen/car_ui_body3_size</item> + </style> + + <style name="Widget.QC" parent="android:Widget.DeviceDefault"/> + + <style name="Widget.QC.SeekBar"> + <item name="android:background">@null</item> + <item name="android:clickable">false</item> + <item name="android:focusable">false</item> + <item name="android:splitTrack">false</item> + </style> +</resources> diff --git a/car-qc-lib/src/com/android/car/qc/QCActionItem.java b/car-qc-lib/src/com/android/car/qc/QCActionItem.java new file mode 100644 index 0000000..4c66bea --- /dev/null +++ b/car-qc-lib/src/com/android/car/qc/QCActionItem.java @@ -0,0 +1,182 @@ +/* + * Copyright (C) 2021 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.qc; + +import android.app.PendingIntent; +import android.graphics.drawable.Icon; +import android.os.Parcel; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +/** + * Quick Control Action that are includes as either start or end actions in {@link QCRow} + */ +public class QCActionItem extends QCItem { + private final boolean mIsChecked; + private final boolean mIsEnabled; + private final boolean mIsAvailable; + private Icon mIcon; + private PendingIntent mAction; + + public QCActionItem(@NonNull @QCItemType String type, boolean isChecked, boolean isEnabled, + boolean isAvailable, @Nullable Icon icon, @Nullable PendingIntent action) { + super(type); + mIsEnabled = isEnabled; + mIsChecked = isChecked; + mIsAvailable = isAvailable; + mIcon = icon; + mAction = action; + } + + public QCActionItem(@NonNull Parcel in) { + super(in); + mIsChecked = in.readBoolean(); + mIsEnabled = in.readBoolean(); + mIsAvailable = in.readBoolean(); + boolean hasIcon = in.readBoolean(); + if (hasIcon) { + mIcon = Icon.CREATOR.createFromParcel(in); + } + boolean hasAction = in.readBoolean(); + if (hasAction) { + mAction = PendingIntent.CREATOR.createFromParcel(in); + } + } + + @Override + public void writeToParcel(Parcel dest, int flags) { + super.writeToParcel(dest, flags); + dest.writeBoolean(mIsChecked); + dest.writeBoolean(mIsEnabled); + dest.writeBoolean(mIsAvailable); + boolean includeIcon = getType().equals(QC_TYPE_ACTION_TOGGLE) && mIcon != null; + dest.writeBoolean(includeIcon); + if (includeIcon) { + mIcon.writeToParcel(dest, flags); + } + boolean hasAction = mAction != null; + dest.writeBoolean(hasAction); + if (hasAction) { + mAction.writeToParcel(dest, flags); + } + } + + @Override + public PendingIntent getPrimaryAction() { + return mAction; + } + + public boolean isChecked() { + return mIsChecked; + } + + public boolean isEnabled() { + return mIsEnabled; + } + + public boolean isAvailable() { + return mIsAvailable; + } + + @Nullable + public Icon getIcon() { + return mIcon; + } + + public static Creator<QCActionItem> CREATOR = new Creator<QCActionItem>() { + @Override + public QCActionItem createFromParcel(Parcel source) { + return new QCActionItem(source); + } + + @Override + public QCActionItem[] newArray(int size) { + return new QCActionItem[size]; + } + }; + + /** + * Builder for {@link QCActionItem}. + */ + public static class Builder { + private final String mType; + private boolean mIsChecked; + private boolean mIsEnabled = true; + private boolean mIsAvailable = true; + private Icon mIcon; + private PendingIntent mAction; + + public Builder(@NonNull @QCItemType String type) { + if (!isValidType(type)) { + throw new IllegalArgumentException("Invalid QCActionItem type provided" + type); + } + mType = type; + } + + /** + * Sets whether or not the action item should be checked. + */ + public Builder setChecked(boolean checked) { + mIsChecked = checked; + return this; + } + + /** + * Sets whether or not the action item should be enabled. + */ + public Builder setEnabled(boolean enabled) { + mIsEnabled = enabled; + return this; + } + + /** + * Sets whether or not the action item is available. + */ + public Builder setAvailable(boolean available) { + mIsAvailable = available; + return this; + } + + /** + * Sets the icon for {@link QC_TYPE_ACTION_TOGGLE} actions + */ + public Builder setIcon(@Nullable Icon icon) { + mIcon = icon; + return this; + } + + /** + * Sets the PendingIntent to be sent when the action item is clicked. + */ + public Builder setAction(@Nullable PendingIntent action) { + mAction = action; + return this; + } + + /** + * Builds the final {@link QCActionItem}. + */ + public QCActionItem build() { + return new QCActionItem(mType, mIsChecked, mIsEnabled, mIsAvailable, mIcon, mAction); + } + + private boolean isValidType(String type) { + return type.equals(QC_TYPE_ACTION_SWITCH) || type.equals(QC_TYPE_ACTION_TOGGLE); + } + } +} diff --git a/car-qc-lib/src/com/android/car/qc/QCItem.java b/car-qc-lib/src/com/android/car/qc/QCItem.java new file mode 100644 index 0000000..77fe30f --- /dev/null +++ b/car-qc-lib/src/com/android/car/qc/QCItem.java @@ -0,0 +1,117 @@ +/* + * Copyright (C) 2021 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.qc; + + +import android.app.PendingIntent; +import android.content.Context; +import android.content.Intent; +import android.os.Parcel; +import android.os.Parcelable; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.StringDef; + +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; + +/** + * Base class for all quick controls elements. + */ +public abstract class QCItem implements Parcelable { + public static final String QC_TYPE_LIST = "QC_TYPE_LIST"; + public static final String QC_TYPE_ROW = "QC_TYPE_ROW"; + public static final String QC_TYPE_TILE = "QC_TYPE_TILE"; + public static final String QC_TYPE_SLIDER = "QC_TYPE_SLIDER"; + public static final String QC_TYPE_ACTION_SWITCH = "QC_TYPE_ACTION_SWITCH"; + public static final String QC_TYPE_ACTION_TOGGLE = "QC_TYPE_ACTION_TOGGLE"; + + public static final String QC_ACTION_TOGGLE_STATE = "QC_ACTION_TOGGLE_STATE"; + public static final String QC_ACTION_SLIDER_VALUE = "QC_ACTION_SLIDER_VALUE"; + + @StringDef(value = { + QC_TYPE_LIST, + QC_TYPE_ROW, + QC_TYPE_TILE, + QC_TYPE_SLIDER, + QC_TYPE_ACTION_SWITCH, + QC_TYPE_ACTION_TOGGLE, + }) + @Retention(RetentionPolicy.SOURCE) + public @interface QCItemType { + } + + private final String mType; + private ActionHandler mActionHandler; + + public QCItem(@NonNull @QCItemType String type) { + mType = type; + } + + public QCItem(@NonNull Parcel in) { + mType = in.readString(); + } + + @NonNull + @QCItemType + public String getType() { + return mType; + } + + @Override + public int describeContents() { + return 0; + } + + @Override + public void writeToParcel(Parcel dest, int flags) { + dest.writeString(mType); + } + + public void setActionHandler(@Nullable ActionHandler handler) { + mActionHandler = handler; + } + + @Nullable + public ActionHandler getActionHandler() { + return mActionHandler; + } + + /** + * Returns the PendingIntent that is sent when the item is clicked. + */ + @Nullable + public abstract PendingIntent getPrimaryAction(); + + /** + * Action handler that can listen for an action to occur and notify listeners. + */ + public interface ActionHandler { + /** + * Callback when an action occurs. + * @param item the QCItem that sent the action + * @param context the context for the action + * @param intent the intent that was sent with the action + */ + void onAction(@NonNull QCItem item, @NonNull Context context, @NonNull Intent intent); + + default boolean isActivity() { + return false; + } + } +} diff --git a/car-qc-lib/src/com/android/car/qc/QCList.java b/car-qc-lib/src/com/android/car/qc/QCList.java new file mode 100644 index 0000000..250b5d8 --- /dev/null +++ b/car-qc-lib/src/com/android/car/qc/QCList.java @@ -0,0 +1,101 @@ +/* + * Copyright (C) 2021 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.qc; + +import android.app.PendingIntent; +import android.os.Parcel; + +import androidx.annotation.NonNull; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +/** + * Wrapping quick controls element that contains QCRow elements. + */ +public class QCList extends QCItem { + private final List<QCRow> mRows; + + public QCList(@NonNull List<QCRow> rows) { + super(QC_TYPE_LIST); + mRows = Collections.unmodifiableList(rows); + } + + public QCList(@NonNull Parcel in) { + super(in); + int rowCount = in.readInt(); + List<QCRow> rows = new ArrayList<>(); + for (int i = 0; i < rowCount; i++) { + rows.add(QCRow.CREATOR.createFromParcel(in)); + } + mRows = Collections.unmodifiableList(rows); + } + + @Override + public void writeToParcel(Parcel dest, int flags) { + super.writeToParcel(dest, flags); + dest.writeInt(mRows.size()); + for (QCRow row : mRows) { + row.writeToParcel(dest, flags); + } + } + + @Override + public PendingIntent getPrimaryAction() { + return null; + } + + @NonNull + public List<QCRow> getRows() { + return mRows; + } + + public static Creator<QCList> CREATOR = new Creator<QCList>() { + @Override + public QCList createFromParcel(Parcel source) { + return new QCList(source); + } + + @Override + public QCList[] newArray(int size) { + return new QCList[size]; + } + }; + + /** + * Builder for {@link QCList}. + */ + public static class Builder { + private final List<QCRow> mRows = new ArrayList<>(); + + /** + * Adds a {@link QCRow} to the list. + */ + public Builder addRow(@NonNull QCRow row) { + mRows.add(row); + return this; + } + + /** + * Builds the final {@link QCList}. + */ + public QCList build() { + return new QCList(mRows); + } + } +} diff --git a/car-qc-lib/src/com/android/car/qc/QCRow.java b/car-qc-lib/src/com/android/car/qc/QCRow.java new file mode 100644 index 0000000..deb4429 --- /dev/null +++ b/car-qc-lib/src/com/android/car/qc/QCRow.java @@ -0,0 +1,267 @@ +/* + * Copyright (C) 2021 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.qc; + +import android.app.PendingIntent; +import android.graphics.drawable.Icon; +import android.os.Parcel; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +/** + * Quick Control Row Element + * ------------------------------------ + * | | Title | | + * | StartItems | Subtitle | EndItems | + * | | Sliders | | + * ------------------------------------ + */ +public class QCRow extends QCItem { + private final String mTitle; + private final String mSubtitle; + private final Icon mStartIcon; + private final boolean mIsStartIconTintable; + private final QCSlider mSlider; + private final List<QCActionItem> mStartItems; + private final List<QCActionItem> mEndItems; + private final PendingIntent mPrimaryAction; + + public QCRow(@Nullable String title, @Nullable String subtitle, + @Nullable PendingIntent primaryAction, @Nullable Icon startIcon, boolean isIconTintable, + @Nullable QCSlider slider, @NonNull List<QCActionItem> startItems, + @NonNull List<QCActionItem> endItems) { + super(QC_TYPE_ROW); + mTitle = title; + mSubtitle = subtitle; + mPrimaryAction = primaryAction; + mStartIcon = startIcon; + mIsStartIconTintable = isIconTintable; + mSlider = slider; + mStartItems = Collections.unmodifiableList(startItems); + mEndItems = Collections.unmodifiableList(endItems); + } + + public QCRow(@NonNull Parcel in) { + super(in); + mTitle = in.readString(); + mSubtitle = in.readString(); + boolean hasIcon = in.readBoolean(); + if (hasIcon) { + mStartIcon = Icon.CREATOR.createFromParcel(in); + } else { + mStartIcon = null; + } + mIsStartIconTintable = in.readBoolean(); + boolean hasSlider = in.readBoolean(); + if (hasSlider) { + mSlider = QCSlider.CREATOR.createFromParcel(in); + } else { + mSlider = null; + } + List<QCActionItem> startItems = new ArrayList<>(); + int startItemCount = in.readInt(); + for (int i = 0; i < startItemCount; i++) { + startItems.add(QCActionItem.CREATOR.createFromParcel(in)); + } + mStartItems = Collections.unmodifiableList(startItems); + List<QCActionItem> endItems = new ArrayList<>(); + int endItemCount = in.readInt(); + for (int i = 0; i < endItemCount; i++) { + endItems.add(QCActionItem.CREATOR.createFromParcel(in)); + } + mEndItems = Collections.unmodifiableList(endItems); + boolean hasPrimaryAction = in.readBoolean(); + if (hasPrimaryAction) { + mPrimaryAction = PendingIntent.CREATOR.createFromParcel(in); + } else { + mPrimaryAction = null; + } + } + + @Override + public void writeToParcel(Parcel dest, int flags) { + super.writeToParcel(dest, flags); + dest.writeString(mTitle); + dest.writeString(mSubtitle); + boolean hasStartIcon = mStartIcon != null; + dest.writeBoolean(hasStartIcon); + if (hasStartIcon) { + mStartIcon.writeToParcel(dest, flags); + } + dest.writeBoolean(mIsStartIconTintable); + boolean hasSlider = mSlider != null; + dest.writeBoolean(hasSlider); + if (hasSlider) { + mSlider.writeToParcel(dest, flags); + } + dest.writeInt(mStartItems.size()); + for (QCActionItem startItem : mStartItems) { + startItem.writeToParcel(dest, flags); + } + dest.writeInt(mEndItems.size()); + for (QCActionItem endItem : mEndItems) { + endItem.writeToParcel(dest, flags); + } + dest.writeBoolean(mPrimaryAction != null); + boolean hasPrimaryAction = mPrimaryAction != null; + if (hasPrimaryAction) { + mPrimaryAction.writeToParcel(dest, flags); + } + } + + @Override + public PendingIntent getPrimaryAction() { + return mPrimaryAction; + } + + @Nullable + public String getTitle() { + return mTitle; + } + + @Nullable + public String getSubtitle() { + return mSubtitle; + } + + @Nullable + public Icon getStartIcon() { + return mStartIcon; + } + + public boolean isStartIconTintable() { + return mIsStartIconTintable; + } + + @Nullable + public QCSlider getSlider() { + return mSlider; + } + + @NonNull + public List<QCActionItem> getStartItems() { + return mStartItems; + } + + @NonNull + public List<QCActionItem> getEndItems() { + return mEndItems; + } + + public static Creator<QCRow> CREATOR = new Creator<QCRow>() { + @Override + public QCRow createFromParcel(Parcel source) { + return new QCRow(source); + } + + @Override + public QCRow[] newArray(int size) { + return new QCRow[size]; + } + }; + + /** + * Builder for {@link QCRow}. + */ + public static class Builder { + private final List<QCActionItem> mStartItems = new ArrayList<>(); + private final List<QCActionItem> mEndItems = new ArrayList<>(); + private Icon mStartIcon; + private boolean mIsStartIconTintable = true; + private String mTitle; + private String mSubtitle; + private QCSlider mSlider; + private PendingIntent mPrimaryAction; + + /** + * Sets the row title. + */ + public Builder setTitle(@Nullable String title) { + mTitle = title; + return this; + } + + /** + * Sets the row subtitle. + */ + public Builder setSubtitle(@Nullable String subtitle) { + mSubtitle = subtitle; + return this; + } + + /** + * Sets the row icon. + */ + public Builder setIcon(@Nullable Icon icon) { + mStartIcon = icon; + return this; + } + + /** + * Sets whether or not the row icon is tintable. + */ + public Builder setIconTintable(boolean tintable) { + mIsStartIconTintable = tintable; + return this; + } + + /** + * Adds a {@link QCSlider} to the slider area. + */ + public Builder addSlider(@Nullable QCSlider slider) { + mSlider = slider; + return this; + } + + /** + * Sets the PendingIntent to be sent when the row is clicked. + */ + public Builder setPrimaryAction(@Nullable PendingIntent action) { + mPrimaryAction = action; + return this; + } + + /** + * Adds a {@link QCActionItem} to the start items area. + */ + public Builder addStartItem(@NonNull QCActionItem item) { + mStartItems.add(item); + return this; + } + + /** + * Adds a {@link QCActionItem} to the end items area. + */ + public Builder addEndItem(@NonNull QCActionItem item) { + mEndItems.add(item); + return this; + } + + /** + * Builds the final {@link QCRow}. + */ + public QCRow build() { + return new QCRow(mTitle, mSubtitle, mPrimaryAction, mStartIcon, mIsStartIconTintable, + mSlider, mStartItems, mEndItems); + } + } +} diff --git a/car-qc-lib/src/com/android/car/qc/QCSlider.java b/car-qc-lib/src/com/android/car/qc/QCSlider.java new file mode 100644 index 0000000..16be5e7 --- /dev/null +++ b/car-qc-lib/src/com/android/car/qc/QCSlider.java @@ -0,0 +1,143 @@ +/* + * Copyright (C) 2021 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.qc; + +import android.app.PendingIntent; +import android.os.Parcel; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +/** + * Quick Control Slider included in {@link QCRow} + */ +public class QCSlider extends QCItem { + private int mMin = 0; + private int mMax = 100; + private int mValue = 0; + private PendingIntent mInputAction; + + public QCSlider(int min, int max, int value, @Nullable PendingIntent inputAction) { + super(QC_TYPE_SLIDER); + mMin = min; + mMax = max; + mValue = value; + mInputAction = inputAction; + } + + public QCSlider(@NonNull Parcel in) { + super(in); + mMin = in.readInt(); + mMax = in.readInt(); + mValue = in.readInt(); + boolean hasAction = in.readBoolean(); + if (hasAction) { + mInputAction = PendingIntent.CREATOR.createFromParcel(in); + } + } + + @Override + public void writeToParcel(Parcel dest, int flags) { + super.writeToParcel(dest, flags); + dest.writeInt(mMin); + dest.writeInt(mMax); + dest.writeInt(mValue); + boolean hasAction = mInputAction != null; + dest.writeBoolean(hasAction); + if (hasAction) { + mInputAction.writeToParcel(dest, flags); + } + } + + @Override + public PendingIntent getPrimaryAction() { + return mInputAction; + } + + public int getMin() { + return mMin; + } + + public int getMax() { + return mMax; + } + + public int getValue() { + return mValue; + } + + public static Creator<QCSlider> CREATOR = new Creator<QCSlider>() { + @Override + public QCSlider createFromParcel(Parcel source) { + return new QCSlider(source); + } + + @Override + public QCSlider[] newArray(int size) { + return new QCSlider[size]; + } + }; + + /** + * Builder for {@link QCSlider}. + */ + public static class Builder { + private int mMin = 0; + private int mMax = 100; + private int mValue = 0; + private PendingIntent mInputAction; + + /** + * Set the minimum allowed value for the slider input. + */ + public Builder setMin(int min) { + mMin = min; + return this; + } + + /** + * Set the maximum allowed value for the slider input. + */ + public Builder setMax(int max) { + mMax = max; + return this; + } + + /** + * Set the current value for the slider input. + */ + public Builder setValue(int value) { + mValue = value; + return this; + } + + /** + * Set the PendingIntent to be sent when the slider value is changed. + */ + public Builder setInputAction(@Nullable PendingIntent inputAction) { + mInputAction = inputAction; + return this; + } + + /** + * Builds the final {@link QCSlider}. + */ + public QCSlider build() { + return new QCSlider(mMin, mMax, mValue, mInputAction); + } + } +} diff --git a/car-qc-lib/src/com/android/car/qc/QCTile.java b/car-qc-lib/src/com/android/car/qc/QCTile.java new file mode 100644 index 0000000..5f891e6 --- /dev/null +++ b/car-qc-lib/src/com/android/car/qc/QCTile.java @@ -0,0 +1,194 @@ +/* + * Copyright (C) 2021 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.qc; + +import android.app.PendingIntent; +import android.graphics.drawable.Icon; +import android.os.Parcel; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +/** + * Quick Control Tile Element + * ------------ + * | -------- | + * | | Icon | | + * | -------- | + * | Subtitle | + * ------------ + */ +public class QCTile extends QCItem { + private final boolean mIsChecked; + private final boolean mIsEnabled; + private final boolean mIsAvailable; + private final String mSubtitle; + private Icon mIcon; + private PendingIntent mAction; + + public QCTile(boolean isChecked, boolean isEnabled, boolean isAvailable, + @Nullable String subtitle, @Nullable Icon icon, @Nullable PendingIntent action) { + super(QC_TYPE_TILE); + mIsEnabled = isEnabled; + mIsChecked = isChecked; + mIsAvailable = isAvailable; + mSubtitle = subtitle; + mIcon = icon; + mAction = action; + } + + public QCTile(@NonNull Parcel in) { + super(in); + mIsChecked = in.readBoolean(); + mIsEnabled = in.readBoolean(); + mIsAvailable = in.readBoolean(); + mSubtitle = in.readString(); + boolean hasIcon = in.readBoolean(); + if (hasIcon) { + mIcon = Icon.CREATOR.createFromParcel(in); + } + boolean hasAction = in.readBoolean(); + if (hasAction) { + mAction = PendingIntent.CREATOR.createFromParcel(in); + } + } + + @Override + public void writeToParcel(Parcel dest, int flags) { + super.writeToParcel(dest, flags); + dest.writeBoolean(mIsChecked); + dest.writeBoolean(mIsEnabled); + dest.writeBoolean(mIsAvailable); + dest.writeString(mSubtitle); + boolean hasIcon = mIcon != null; + dest.writeBoolean(hasIcon); + if (hasIcon) { + mIcon.writeToParcel(dest, flags); + } + boolean hasAction = mAction != null; + dest.writeBoolean(hasAction); + if (hasAction) { + mAction.writeToParcel(dest, flags); + } + } + + @Override + public PendingIntent getPrimaryAction() { + return mAction; + } + + public boolean isChecked() { + return mIsChecked; + } + + public boolean isEnabled() { + return mIsEnabled; + } + + public boolean isAvailable() { + return mIsAvailable; + } + + @Nullable + public String getSubtitle() { + return mSubtitle; + } + + @Nullable + public Icon getIcon() { + return mIcon; + } + + public static Creator<QCTile> CREATOR = new Creator<QCTile>() { + @Override + public QCTile createFromParcel(Parcel source) { + return new QCTile(source); + } + + @Override + public QCTile[] newArray(int size) { + return new QCTile[size]; + } + }; + + /** + * Builder for {@link QCTile}. + */ + public static class Builder { + private boolean mIsChecked; + private boolean mIsEnabled = true; + private boolean mIsAvailable = true; + private String mSubtitle; + private Icon mIcon; + private PendingIntent mAction; + + /** + * Sets whether or not the tile should be checked. + */ + public Builder setChecked(boolean checked) { + mIsChecked = checked; + return this; + } + + /** + * Sets whether or not the tile should be enabled. + */ + public Builder setEnabled(boolean enabled) { + mIsEnabled = enabled; + return this; + } + + /** + * Sets whether or not the action item is available. + */ + public Builder setAvailable(boolean available) { + mIsAvailable = available; + return this; + } + + /** + * Sets the tile's subtitle. + */ + public Builder setSubtitle(@Nullable String subtitle) { + mSubtitle = subtitle; + return this; + } + + /** + * Sets the tile's icon. + */ + public Builder setIcon(@Nullable Icon icon) { + mIcon = icon; + return this; + } + + /** + * Sets the PendingIntent to be sent when the tile is clicked. + */ + public Builder setAction(@Nullable PendingIntent action) { + mAction = action; + return this; + } + + /** + * Builds the final {@link QCTile}. + */ + public QCTile build() { + return new QCTile(mIsChecked, mIsEnabled, mIsAvailable, mSubtitle, mIcon, mAction); + } + } +} diff --git a/car-qc-lib/src/com/android/car/qc/controller/BaseQCController.java b/car-qc-lib/src/com/android/car/qc/controller/BaseQCController.java new file mode 100644 index 0000000..ce2bea3 --- /dev/null +++ b/car-qc-lib/src/com/android/car/qc/controller/BaseQCController.java @@ -0,0 +1,91 @@ +/* + * Copyright (C) 2021 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.qc.controller; + +import android.content.Context; + +import androidx.annotation.Nullable; +import androidx.annotation.UiThread; +import androidx.lifecycle.Observer; + +import com.android.car.qc.QCItem; + +import java.util.ArrayList; +import java.util.List; + +/** + * Base controller class for Quick Controls. + */ +public abstract class BaseQCController implements QCItemCallback { + protected final Context mContext; + protected final List<Observer<QCItem>> mObservers = new ArrayList<>(); + protected boolean mShouldListen = false; + protected boolean mWasListening = false; + protected QCItem mQCItem; + + public BaseQCController(Context context) { + mContext = context; + } + + /** + * Update whether or not the controller should be listening to updates from the provider. + */ + public void listen(boolean shouldListen) { + mShouldListen = shouldListen; + updateListening(); + } + + /** + * Add a QCItem observer to the controller. + */ + @UiThread + public void addObserver(Observer<QCItem> observer) { + mObservers.add(observer); + updateListening(); + } + + /** + * Remove a QCItem observer from the controller. + */ + @UiThread + public void removeObserver(Observer<QCItem> observer) { + mObservers.remove(observer); + updateListening(); + } + + @UiThread + @Override + public void onQCItemUpdated(@Nullable QCItem item) { + mQCItem = item; + mObservers.forEach(o -> o.onChanged(mQCItem)); + } + + /** + * Destroy the controller. This should be called when the controller is no longer needed so + * the listeners can be cleaned up. + */ + public void destroy() { + mShouldListen = false; + mObservers.clear(); + updateListening(); + } + + /** + * Subclasses must override this method to handle a listening update. + */ + protected abstract void updateListening(); +} diff --git a/car-qc-lib/src/com/android/car/qc/controller/LocalQCController.java b/car-qc-lib/src/com/android/car/qc/controller/LocalQCController.java new file mode 100644 index 0000000..01bd6f8 --- /dev/null +++ b/car-qc-lib/src/com/android/car/qc/controller/LocalQCController.java @@ -0,0 +1,65 @@ +/* + * Copyright (C) 2021 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.qc.controller; + +import android.content.Context; + +import com.android.car.qc.provider.BaseLocalQCProvider; + +/** + * Controller for binding to local quick control providers. + */ +public class LocalQCController extends BaseQCController { + + private final BaseLocalQCProvider mProvider; + + private final BaseLocalQCProvider.Notifier mProviderNotifier = + new BaseLocalQCProvider.Notifier() { + @Override + public void notifyUpdate() { + if (mShouldListen && !mObservers.isEmpty()) { + onQCItemUpdated(mProvider.getQCItem()); + } + } + }; + + public LocalQCController(Context context, BaseLocalQCProvider provider) { + super(context); + mProvider = provider; + mProvider.setNotifier(mProviderNotifier); + mQCItem = mProvider.getQCItem(); + } + + @Override + protected void updateListening() { + boolean listen = mShouldListen && !mObservers.isEmpty(); + if (mWasListening != listen) { + mWasListening = listen; + mProvider.shouldListen(listen); + if (listen) { + mQCItem = mProvider.getQCItem(); + onQCItemUpdated(mQCItem); + } + } + } + + @Override + public void destroy() { + super.destroy(); + mProvider.onDestroy(); + } +} diff --git a/car-qc-lib/src/com/android/car/qc/controller/QCItemCallback.java b/car-qc-lib/src/com/android/car/qc/controller/QCItemCallback.java new file mode 100644 index 0000000..b5efdef --- /dev/null +++ b/car-qc-lib/src/com/android/car/qc/controller/QCItemCallback.java @@ -0,0 +1,33 @@ +/* + * Copyright (C) 2021 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.qc.controller; + +import androidx.annotation.Nullable; + +import com.android.car.qc.QCItem; + +/** + * Callback to be executed when a QCItem changes. + */ +public interface QCItemCallback { + /** + * Called when QCItem is updated. + * + * @param item The updated QCItem. + */ + void onQCItemUpdated(@Nullable QCItem item); +} diff --git a/car-qc-lib/src/com/android/car/qc/controller/RemoteQCController.java b/car-qc-lib/src/com/android/car/qc/controller/RemoteQCController.java new file mode 100644 index 0000000..c98ca86 --- /dev/null +++ b/car-qc-lib/src/com/android/car/qc/controller/RemoteQCController.java @@ -0,0 +1,273 @@ +/* + * Copyright (C) 2021 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.qc.controller; + +import static com.android.car.qc.provider.BaseQCProvider.EXTRA_ITEM; +import static com.android.car.qc.provider.BaseQCProvider.EXTRA_URI; +import static com.android.car.qc.provider.BaseQCProvider.METHOD_BIND; +import static com.android.car.qc.provider.BaseQCProvider.METHOD_DESTROY; +import static com.android.car.qc.provider.BaseQCProvider.METHOD_SUBSCRIBE; +import static com.android.car.qc.provider.BaseQCProvider.METHOD_UNSUBSCRIBE; + +import android.content.ContentProviderClient; +import android.content.Context; +import android.database.ContentObserver; +import android.net.Uri; +import android.os.Bundle; +import android.os.Handler; +import android.os.HandlerExecutor; +import android.os.HandlerThread; +import android.os.Looper; +import android.os.Parcelable; +import android.os.RemoteException; +import android.util.ArrayMap; +import android.util.Log; +import android.util.Pair; + +import androidx.annotation.NonNull; +import androidx.annotation.VisibleForTesting; +import androidx.annotation.WorkerThread; + +import com.android.car.qc.QCItem; + +import java.util.concurrent.Executor; + +/** + * Controller for binding to remote quick control providers. + */ +public class RemoteQCController extends BaseQCController { + private static final String TAG = "RemoteQCController"; + private static final long PROVIDER_ANR_TIMEOUT = 3000L; + + private final Uri mUri; + private final Executor mBackgroundExecutor; + private final HandlerThread mBackgroundHandlerThread; + private final ArrayMap<Pair<Uri, QCItemCallback>, QCObserver> mObserverLookup = + new ArrayMap<>(); + + public RemoteQCController(Context context, Uri uri) { + super(context); + mUri = uri; + mBackgroundHandlerThread = new HandlerThread(/* name= */ TAG + "HandlerThread"); + mBackgroundHandlerThread.start(); + mBackgroundExecutor = new HandlerExecutor( + new Handler(mBackgroundHandlerThread.getLooper())); + } + + @VisibleForTesting + RemoteQCController(Context context, Uri uri, Executor backgroundExecutor) { + super(context); + mUri = uri; + mBackgroundHandlerThread = null; + mBackgroundExecutor = backgroundExecutor; + } + + @Override + protected void updateListening() { + boolean listen = mShouldListen && !mObservers.isEmpty(); + mBackgroundExecutor.execute(() -> updateListeningBg(listen)); + } + + @Override + public void destroy() { + super.destroy(); + if (mBackgroundHandlerThread != null) { + mBackgroundHandlerThread.quit(); + } + try (ContentProviderClient client = getClient()) { + if (client == null) { + return; + } + Bundle b = new Bundle(); + b.putParcelable(EXTRA_URI, mUri); + try { + client.call(METHOD_DESTROY, /* arg= */ null, b); + } catch (Exception e) { + Log.d(TAG, "Error destroying QCItem", e); + } + } + } + + @WorkerThread + private void updateListeningBg(boolean isListening) { + if (mWasListening != isListening) { + mWasListening = isListening; + if (isListening) { + registerQCCallback(mContext.getMainExecutor(), /* callback= */ this); + // Update one-time on a different thread so that it can display in parallel + mBackgroundExecutor.execute(this::updateQCItem); + } else { + unregisterQCCallback(this); + } + } + } + + @WorkerThread + private void updateQCItem() { + try { + QCItem item = bind(); + mContext.getMainExecutor().execute(() -> onQCItemUpdated(item)); + } catch (Exception e) { + Log.d(TAG, "Error fetching QCItem", e); + } + } + + private QCItem bind() { + try (ContentProviderClient provider = getClient()) { + if (provider == null) { + return null; + } + Bundle extras = new Bundle(); + extras.putParcelable(EXTRA_URI, mUri); + Bundle res = provider.call(METHOD_BIND, /* arg= */ null, extras); + if (res == null) { + return null; + } + res.setDefusable(true); + res.setClassLoader(QCItem.class.getClassLoader()); + Parcelable parcelable = res.getParcelable(EXTRA_ITEM); + if (parcelable instanceof QCItem) { + return (QCItem) parcelable; + } + return null; + } catch (RemoteException e) { + Log.d(TAG, "Error binding QCItem", e); + return null; + } + } + + private void subscribe() { + try (ContentProviderClient client = getClient()) { + if (client == null) { + return; + } + Bundle b = new Bundle(); + b.putParcelable(EXTRA_URI, mUri); + try { + client.call(METHOD_SUBSCRIBE, /* arg= */ null, b); + } catch (Exception e) { + Log.d(TAG, "Error subscribing to QCItem", e); + } + } + } + + private void unsubscribe() { + try (ContentProviderClient client = getClient()) { + if (client == null) { + return; + } + Bundle b = new Bundle(); + b.putParcelable(EXTRA_URI, mUri); + try { + client.call(METHOD_UNSUBSCRIBE, /* arg= */ null, b); + } catch (Exception e) { + Log.d(TAG, "Error unsubscribing from QCItem", e); + } + } + } + + @VisibleForTesting + ContentProviderClient getClient() { + ContentProviderClient client = mContext.getContentResolver() + .acquireContentProviderClient(mUri); + if (client == null) { + return null; + } + client.setDetectNotResponding(PROVIDER_ANR_TIMEOUT); + return client; + } + + private void registerQCCallback(@NonNull Executor executor, @NonNull QCItemCallback callback) { + getObserver(callback, new QCObserver(mUri, executor, callback)).startObserving(); + } + + private void unregisterQCCallback(@NonNull QCItemCallback callback) { + synchronized (mObserverLookup) { + QCObserver observer = mObserverLookup.remove(new Pair<>(mUri, callback)); + if (observer != null) { + observer.stopObserving(); + } + } + } + + private QCObserver getObserver(QCItemCallback callback, QCObserver observer) { + Pair<Uri, QCItemCallback> key = new Pair<>(mUri, callback); + synchronized (mObserverLookup) { + QCObserver oldObserver = mObserverLookup.put(key, observer); + if (oldObserver != null) { + oldObserver.stopObserving(); + } + } + return observer; + } + + private class QCObserver { + private final Uri mUri; + private final Executor mExecutor; + private final QCItemCallback mCallback; + private boolean mIsSubscribed; + + private final Runnable mUpdateItem = new Runnable() { + @Override + public void run() { + trySubscribe(); + QCItem item = bind(); + mExecutor.execute(() -> mCallback.onQCItemUpdated(item)); + } + }; + + private final ContentObserver mObserver = new ContentObserver( + new Handler(Looper.getMainLooper())) { + @Override + public void onChange(boolean selfChange) { + android.os.AsyncTask.execute(mUpdateItem); + } + }; + + QCObserver(Uri uri, Executor executor, QCItemCallback callback) { + mUri = uri; + mExecutor = executor; + mCallback = callback; + } + + void startObserving() { + ContentProviderClient provider = + mContext.getContentResolver().acquireContentProviderClient(mUri); + if (provider != null) { + provider.close(); + mContext.getContentResolver().registerContentObserver( + mUri, /* notifyForDescendants= */ true, mObserver); + trySubscribe(); + } + } + + void trySubscribe() { + if (!mIsSubscribed) { + subscribe(); + mIsSubscribed = true; + } + } + + void stopObserving() { + mContext.getContentResolver().unregisterContentObserver(mObserver); + if (mIsSubscribed) { + unsubscribe(); + mIsSubscribed = false; + } + } + } +} diff --git a/car-qc-lib/src/com/android/car/qc/provider/BaseLocalQCProvider.java b/car-qc-lib/src/com/android/car/qc/provider/BaseLocalQCProvider.java new file mode 100644 index 0000000..764d2a3 --- /dev/null +++ b/car-qc-lib/src/com/android/car/qc/provider/BaseLocalQCProvider.java @@ -0,0 +1,97 @@ +/* + * Copyright (C) 2021 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.qc.provider; + +import android.content.Context; + +import com.android.car.qc.QCItem; + +/** + * Base class for local Quick Control providers. + */ +public abstract class BaseLocalQCProvider { + + /** + * Callback to be executed when the QCItem updates. + */ + public interface Notifier { + /** + * Called when the QCItem has been updated. + */ + default void notifyUpdate() { + } + } + + private Notifier mNotifier; + private boolean mIsListening; + protected final Context mContext; + + public BaseLocalQCProvider(Context context) { + mContext = context; + } + + /** + * Set the notifier that should be called when the QCItem updates. + */ + public void setNotifier(Notifier notifier) { + mNotifier = notifier; + } + + /** + * Update whether or not the provider should be listening for live updates. + */ + public void shouldListen(boolean listen) { + if (mIsListening == listen) { + return; + } + mIsListening = listen; + if (listen) { + onSubscribed(); + } else { + onUnsubscribed(); + } + } + + /** + * Method to create and return a {@link QCItem}. + */ + public abstract QCItem getQCItem(); + + /** + * Called to inform the provider that it has been subscribed to. + */ + protected void onSubscribed() { + } + + /** + * Called to inform the provider that it has been unsubscribed from. + */ + protected void onUnsubscribed() { + } + + /** + * Called to inform the provider that it is being destroyed. + */ + public void onDestroy() { + } + + protected void notifyChange() { + if (mNotifier != null) { + mNotifier.notifyUpdate(); + } + } +} diff --git a/car-qc-lib/src/com/android/car/qc/provider/BaseQCProvider.java b/car-qc-lib/src/com/android/car/qc/provider/BaseQCProvider.java new file mode 100644 index 0000000..61db361 --- /dev/null +++ b/car-qc-lib/src/com/android/car/qc/provider/BaseQCProvider.java @@ -0,0 +1,231 @@ +/* + * Copyright (C) 2021 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.qc.provider; + +import android.content.ContentProvider; +import android.content.ContentValues; +import android.database.Cursor; +import android.net.Uri; +import android.os.Bundle; +import android.os.Handler; +import android.os.Looper; +import android.os.Process; +import android.os.StrictMode; +import android.util.Log; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.android.car.qc.QCItem; + +import java.util.Set; + +/** + * Base Quick Controls provider implementation. + */ +public abstract class BaseQCProvider extends ContentProvider { + public static final String METHOD_BIND = "QC_METHOD_BIND"; + public static final String METHOD_SUBSCRIBE = "QC_METHOD_SUBSCRIBE"; + public static final String METHOD_UNSUBSCRIBE = "QC_METHOD_UNSUBSCRIBE"; + public static final String METHOD_DESTROY = "QC_METHOD_DESTROY"; + public static final String EXTRA_URI = "QC_EXTRA_URI"; + public static final String EXTRA_ITEM = "QC_EXTRA_ITEM"; + + private static final String TAG = "BaseQCProvider"; + private static final long QC_ANR_TIMEOUT = 3000L; + private static final Handler MAIN_THREAD_HANDLER = new Handler(Looper.getMainLooper()); + private String mCallbackMethod; + private final Runnable mAnr = () -> { + Process.sendSignal(Process.myPid(), Process.SIGNAL_QUIT); + Log.e(TAG, "Timed out while handling QC method " + mCallbackMethod); + }; + + @Override + public boolean onCreate() { + return true; + } + + @Override + public Bundle call(String method, String arg, Bundle extras) { + enforceCallingPermissions(); + + Uri uri = getUriWithoutUserId(validateIncomingUriOrNull( + extras.getParcelable(EXTRA_URI))); + switch(method) { + case METHOD_BIND: + QCItem item = handleBind(uri); + Bundle b = new Bundle(); + b.putParcelable(EXTRA_ITEM, item); + return b; + case METHOD_SUBSCRIBE: + handleSubscribe(uri); + break; + case METHOD_UNSUBSCRIBE: + handleUnsubscribe(uri); + break; + case METHOD_DESTROY: + handleDestroy(uri); + break; + } + return super.call(method, arg, extras); + } + + @Override + public Cursor query(Uri uri, String[] projection, String selection, String[] selectionArgs, + String sortOrder) { + return null; + } + + @Override + public String getType(Uri uri) { + return null; + } + + @Override + public Uri insert(Uri uri, ContentValues values) { + return null; + } + + @Override + public int delete(Uri uri, String selection, String[] selectionArgs) { + return 0; + } + + @Override + public int update(Uri uri, ContentValues values, String selection, String[] selectionArgs) { + return 0; + } + + /** + * Method to create and return a {@link QCItem}. + * + * onBind is expected to return as quickly as possible. Therefore, no network or other IO + * will be allowed. Any loading that needs to be done should happen in the background and + * should then notify the content resolver of the change when ready to provide the + * complete data in onBind. + */ + @Nullable + protected QCItem onBind(@NonNull Uri uri) { + return null; + } + + /** + * Called to inform an app that an item has been subscribed to. + * + * Subscribing is a way that a host can notify apps of which QCItems they would like to + * receive updates for. The providing apps are expected to keep the content up to date + * and notify of change via the content resolver. + */ + protected void onSubscribed(@NonNull Uri uri) { + } + + /** + * Called to inform an app that an item has been unsubscribed from. + * + * This is used to notify providing apps that a host is no longer listening + * to updates, so any background processes and/or listeners should be removed. + */ + protected void onUnsubscribed(@NonNull Uri uri) { + } + + /** + * Called to inform an app that an item is being destroyed. + * + * This is used to notify providing apps that a host is no longer going to use this QCItem + * instance, so the relevant elements should be cleaned up. + */ + protected void onDestroy(@NonNull Uri uri) { + } + + /** + * Returns a Set of packages that are allowed to call this provider. + */ + @NonNull + protected abstract Set<String> getAllowlistedPackages(); + + private QCItem handleBind(Uri uri) { + mCallbackMethod = "handleBind"; + MAIN_THREAD_HANDLER.postDelayed(mAnr, QC_ANR_TIMEOUT); + try { + return onBindStrict(uri); + } finally { + MAIN_THREAD_HANDLER.removeCallbacks(mAnr); + } + } + + private QCItem onBindStrict(@NonNull Uri uri) { + StrictMode.ThreadPolicy oldPolicy = StrictMode.getThreadPolicy(); + try { + StrictMode.setThreadPolicy(new StrictMode.ThreadPolicy.Builder() + .detectAll() + .penaltyDeath() + .build()); + return onBind(uri); + } finally { + StrictMode.setThreadPolicy(oldPolicy); + } + } + + private void handleSubscribe(@NonNull Uri uri) { + mCallbackMethod = "handleSubscribe"; + MAIN_THREAD_HANDLER.postDelayed(mAnr, QC_ANR_TIMEOUT); + try { + onSubscribed(uri); + } finally { + MAIN_THREAD_HANDLER.removeCallbacks(mAnr); + } + } + + private void handleUnsubscribe(@NonNull Uri uri) { + mCallbackMethod = "handleUnsubscribe"; + MAIN_THREAD_HANDLER.postDelayed(mAnr, QC_ANR_TIMEOUT); + try { + onUnsubscribed(uri); + } finally { + MAIN_THREAD_HANDLER.removeCallbacks(mAnr); + } + } + + private void handleDestroy(@NonNull Uri uri) { + mCallbackMethod = "handleDestroy"; + MAIN_THREAD_HANDLER.postDelayed(mAnr, QC_ANR_TIMEOUT); + try { + onDestroy(uri); + } finally { + MAIN_THREAD_HANDLER.removeCallbacks(mAnr); + } + } + + private Uri validateIncomingUriOrNull(Uri uri) { + if (uri == null) { + throw new IllegalArgumentException("Uri cannot be null"); + } + return validateIncomingUri(uri); + } + + private void enforceCallingPermissions() { + String callingPackage = getCallingPackage(); + if (callingPackage == null) { + throw new IllegalArgumentException("Calling package cannot be null"); + } + if (!getAllowlistedPackages().contains(callingPackage)) { + throw new SecurityException( + String.format("%s is not permitted to access provider: %s", callingPackage, + getClass().getName())); + } + } +} diff --git a/car-qc-lib/src/com/android/car/qc/view/QCListView.java b/car-qc-lib/src/com/android/car/qc/view/QCListView.java new file mode 100644 index 0000000..9aba976 --- /dev/null +++ b/car-qc-lib/src/com/android/car/qc/view/QCListView.java @@ -0,0 +1,101 @@ +/* + * Copyright (C) 2021 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.qc.view; + +import static com.android.car.qc.view.QCView.QCActionListener; + +import android.content.Context; +import android.util.AttributeSet; +import android.widget.LinearLayout; + +import androidx.lifecycle.Observer; + +import com.android.car.qc.QCItem; +import com.android.car.qc.QCList; + +/** + * Quick Controls view for {@link QCList} instances. + */ +public class QCListView extends LinearLayout implements Observer<QCItem> { + + private QCActionListener mActionListener; + + public QCListView(Context context) { + super(context); + init(); + } + + public QCListView(Context context, AttributeSet attrs) { + super(context, attrs); + init(); + } + + public QCListView(Context context, AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + init(); + } + + public QCListView(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) { + super(context, attrs, defStyleAttr, defStyleRes); + init(); + } + + private void init() { + setOrientation(VERTICAL); + } + + /** + * Set the view's {@link QCActionListener}. This listener will propagate to all QCRows. + */ + public void setActionListener(QCActionListener listener) { + mActionListener = listener; + for (int i = 0; i < getChildCount(); i++) { + QCRowView view = (QCRowView) getChildAt(i); + view.setActionListener(mActionListener); + } + } + + @Override + public void onChanged(QCItem qcItem) { + if (qcItem == null) { + removeAllViews(); + return; + } + if (!qcItem.getType().equals(QCItem.QC_TYPE_LIST)) { + throw new IllegalArgumentException("Expected QCList type for QCListView but got " + + qcItem.getType()); + } + QCList qcList = (QCList) qcItem; + int rowCount = qcList.getRows().size(); + for (int i = 0; i < rowCount; i++) { + if (getChildAt(i) != null) { + QCRowView view = (QCRowView) getChildAt(i); + view.setRow(qcList.getRows().get(i)); + view.setActionListener(mActionListener); + } else { + QCRowView view = new QCRowView(getContext()); + view.setRow(qcList.getRows().get(i)); + view.setActionListener(mActionListener); + addView(view); + } + } + if (getChildCount() > rowCount) { + // remove extra rows + removeViews(rowCount, getChildCount() - rowCount); + } + } +} diff --git a/car-qc-lib/src/com/android/car/qc/view/QCRowView.java b/car-qc-lib/src/com/android/car/qc/view/QCRowView.java new file mode 100644 index 0000000..9fc5743 --- /dev/null +++ b/car-qc-lib/src/com/android/car/qc/view/QCRowView.java @@ -0,0 +1,440 @@ +/* + * Copyright (C) 2021 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.qc.view; + +import static com.android.car.qc.QCItem.QC_ACTION_SLIDER_VALUE; +import static com.android.car.qc.QCItem.QC_ACTION_TOGGLE_STATE; +import static com.android.car.qc.QCItem.QC_TYPE_ACTION_SWITCH; +import static com.android.car.qc.view.QCView.QCActionListener; + +import android.app.PendingIntent; +import android.content.Context; +import android.content.Intent; +import android.graphics.drawable.Drawable; +import android.os.Handler; +import android.text.BidiFormatter; +import android.text.TextDirectionHeuristics; +import android.text.TextUtils; +import android.util.AttributeSet; +import android.util.Log; +import android.view.KeyEvent; +import android.view.LayoutInflater; +import android.view.MotionEvent; +import android.view.View; +import android.view.ViewGroup; +import android.widget.FrameLayout; +import android.widget.ImageView; +import android.widget.LinearLayout; +import android.widget.SeekBar; +import android.widget.Switch; +import android.widget.TextView; + +import androidx.annotation.ColorInt; +import androidx.annotation.LayoutRes; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.android.car.qc.QCActionItem; +import com.android.car.qc.QCItem; +import com.android.car.qc.QCRow; +import com.android.car.qc.QCSlider; +import com.android.car.qc.R; +import com.android.car.ui.utils.DirectManipulationHelper; +import com.android.car.ui.uxr.DrawableStateToggleButton; + +/** + * Quick Controls view for {@link QCRow} instances. + */ +public class QCRowView extends FrameLayout { + private static final String TAG = "QCRowView"; + + private LayoutInflater mLayoutInflater; + private BidiFormatter mBidiFormatter; + private View mContentView; + private TextView mTitle; + private TextView mSubtitle; + private ImageView mStartIcon; + @ColorInt + private int mStartIconTint; + private LinearLayout mStartItemsContainer; + private LinearLayout mEndItemsContainer; + private LinearLayout mSeekBarContainer; + private SeekBar mSeekBar; + private QCActionListener mActionListener; + private boolean mInDirectManipulationMode; + + private QCSeekbarChangeListener mSeekbarChangeListener; + private final View.OnKeyListener mSeekBarKeyListener = new View.OnKeyListener() { + @Override + public boolean onKey(View v, int keyCode, KeyEvent event) { + if (mSeekBar == null) { + return false; + } + // Consume nudge events in direct manipulation mode. + if (mInDirectManipulationMode + && (keyCode == KeyEvent.KEYCODE_DPAD_LEFT + || keyCode == KeyEvent.KEYCODE_DPAD_RIGHT + || keyCode == KeyEvent.KEYCODE_DPAD_UP + || keyCode == KeyEvent.KEYCODE_DPAD_DOWN)) { + return true; + } + + // Handle events to enter or exit direct manipulation mode. + if (keyCode == KeyEvent.KEYCODE_DPAD_CENTER) { + if (event.getAction() == KeyEvent.ACTION_DOWN) { + setInDirectManipulationMode(v, mSeekBar, !mInDirectManipulationMode); + } + return true; + } + if (keyCode == KeyEvent.KEYCODE_BACK) { + if (mInDirectManipulationMode) { + if (event.getAction() == KeyEvent.ACTION_DOWN) { + setInDirectManipulationMode(v, mSeekBar, false); + } + return true; + } + } + + // Don't propagate confirm keys to the SeekBar to prevent a ripple effect on the thumb. + if (KeyEvent.isConfirmKey(keyCode)) { + return false; + } + + if (event.getAction() == KeyEvent.ACTION_DOWN) { + return mSeekBar.onKeyDown(keyCode, event); + } else { + return mSeekBar.onKeyUp(keyCode, event); + } + } + }; + + private final View.OnFocusChangeListener mSeekBarFocusChangeListener = + (v, hasFocus) -> { + if (!hasFocus && mInDirectManipulationMode && mSeekBar != null) { + setInDirectManipulationMode(v, mSeekBar, false); + } + }; + + private final View.OnGenericMotionListener mSeekBarScrollListener = + (v, event) -> { + if (!mInDirectManipulationMode || mSeekBar == null) { + return false; + } + int adjustment = Math.round(event.getAxisValue(MotionEvent.AXIS_SCROLL)); + if (adjustment == 0) { + return false; + } + int count = Math.abs(adjustment); + int keyCode = + adjustment < 0 ? KeyEvent.KEYCODE_DPAD_LEFT : KeyEvent.KEYCODE_DPAD_RIGHT; + KeyEvent downEvent = new KeyEvent(event.getDownTime(), event.getEventTime(), + KeyEvent.ACTION_DOWN, keyCode, /* repeat= */ 0); + KeyEvent upEvent = new KeyEvent(event.getDownTime(), event.getEventTime(), + KeyEvent.ACTION_UP, keyCode, /* repeat= */ 0); + for (int i = 0; i < count; i++) { + mSeekBar.onKeyDown(keyCode, downEvent); + mSeekBar.onKeyUp(keyCode, upEvent); + } + return true; + }; + + QCRowView(Context context) { + super(context); + init(context); + } + + QCRowView(Context context, AttributeSet attrs) { + super(context, attrs); + init(context); + } + + QCRowView(Context context, AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + init(context); + } + + QCRowView(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) { + super(context, attrs, defStyleAttr, defStyleRes); + init(context); + } + + private void init(Context context) { + mLayoutInflater = LayoutInflater.from(context); + mBidiFormatter = BidiFormatter.getInstance(); + mLayoutInflater.inflate(R.layout.qc_row_view, /* root= */ this); + mContentView = findViewById(R.id.qc_row_content); + mTitle = findViewById(R.id.qc_title); + mSubtitle = findViewById(R.id.qc_summary); + mStartIcon = findViewById(R.id.qc_icon); + mStartItemsContainer = findViewById(R.id.qc_row_start_items); + mEndItemsContainer = findViewById(R.id.qc_row_end_items); + mSeekBarContainer = findViewById(R.id.qc_seekbar_wrapper); + mSeekBar = findViewById(R.id.seekbar); + } + + void setActionListener(QCActionListener listener) { + mActionListener = listener; + } + + void setRow(QCRow row) { + if (row == null) { + setVisibility(GONE); + return; + } + setVisibility(VISIBLE); + if (row.getPrimaryAction() != null || row.getActionHandler() != null) { + mContentView.setOnClickListener(v -> { + fireAction(row, /* intent= */ null); + }); + } + if (!TextUtils.isEmpty(row.getTitle())) { + mTitle.setVisibility(VISIBLE); + mTitle.setText( + mBidiFormatter.unicodeWrap(row.getTitle(), TextDirectionHeuristics.LOCALE)); + } else { + mTitle.setVisibility(GONE); + } + if (!TextUtils.isEmpty(row.getSubtitle())) { + mSubtitle.setVisibility(VISIBLE); + mSubtitle.setText( + mBidiFormatter.unicodeWrap(row.getSubtitle(), TextDirectionHeuristics.LOCALE)); + } else { + mSubtitle.setVisibility(GONE); + } + if (row.getStartIcon() != null) { + mStartIcon.setVisibility(VISIBLE); + Drawable drawable = row.getStartIcon().loadDrawable(getContext()); + if (drawable != null && row.isStartIconTintable()) { + if (mStartIconTint == 0) { + mStartIconTint = getContext().getColor(R.color.qc_start_icon_color); + } + drawable.setTint(mStartIconTint); + } + mStartIcon.setImageDrawable(drawable); + } else { + mStartIcon.setImageDrawable(null); + mStartIcon.setVisibility(GONE); + } + QCSlider slider = row.getSlider(); + if (slider != null) { + mSeekBarContainer.setVisibility(View.VISIBLE); + initSlider(slider); + } else { + mSeekBarContainer.setVisibility(View.GONE); + } + + int startItemCount = row.getStartItems().size(); + for (int i = 0; i < startItemCount; i++) { + QCActionItem action = row.getStartItems().get(i); + initActionItem(mStartItemsContainer, mStartItemsContainer.getChildAt(i), action); + } + if (mStartItemsContainer.getChildCount() > startItemCount) { + // remove extra items + mStartItemsContainer.removeViews(startItemCount, + mStartItemsContainer.getChildCount() - startItemCount); + } + if (startItemCount == 0) { + mStartItemsContainer.setVisibility(View.GONE); + } else { + mStartItemsContainer.setVisibility(View.VISIBLE); + } + + int endItemCount = row.getEndItems().size(); + for (int i = 0; i < endItemCount; i++) { + QCActionItem action = row.getEndItems().get(i); + initActionItem(mEndItemsContainer, mEndItemsContainer.getChildAt(i), action); + } + if (mEndItemsContainer.getChildCount() > endItemCount) { + // remove extra items + mEndItemsContainer.removeViews(endItemCount, + mEndItemsContainer.getChildCount() - endItemCount); + } + if (endItemCount == 0) { + mEndItemsContainer.setVisibility(View.GONE); + } else { + mEndItemsContainer.setVisibility(View.VISIBLE); + } + } + + private void initActionItem(@NonNull ViewGroup root, @Nullable View actionView, + @NonNull QCActionItem action) { + if (action.getType().equals(QC_TYPE_ACTION_SWITCH)) { + initSwitchView(action, root, actionView); + } else { + initToggleView(action, root, actionView); + } + } + + private void initSwitchView(QCActionItem action, ViewGroup root, View actionView) { + FrameLayout switchFrame = actionView == null ? null : actionView.findViewById( + R.id.qc_switch_frame); + if (switchFrame == null) { + actionView = createActionView(root, actionView, R.layout.qc_action_switch); + switchFrame = actionView.requireViewById(R.id.qc_switch_frame); + } + Switch switchView = actionView.requireViewById(android.R.id.switch_widget); + + switchFrame.setEnabled(action.isEnabled()); + switchFrame.setOnClickListener(v -> switchView.toggle()); + switchView.setChecked(action.isChecked()); + switchView.setOnCheckedChangeListener( + (buttonView, isChecked) -> { + Intent intent = new Intent(); + intent.putExtra(QC_ACTION_TOGGLE_STATE, isChecked); + fireAction(action, intent); + }); + } + + private void initToggleView(QCActionItem action, ViewGroup root, View actionView) { + DrawableStateToggleButton toggleButton = + actionView == null ? null : actionView.findViewById(R.id.qc_toggle_button); + if (toggleButton == null) { + actionView = createActionView(root, actionView, R.layout.qc_action_toggle); + toggleButton = actionView.requireViewById(R.id.qc_toggle_button); + } + toggleButton.setText(null); + toggleButton.setTextOn(null); + toggleButton.setTextOff(null); + toggleButton.setOnCheckedChangeListener(null); + Drawable icon = QCViewUtils.getInstance(mContext).getToggleIcon( + action.getIcon(), action.isAvailable()); + toggleButton.setButtonDrawable(icon); + toggleButton.setChecked(action.isChecked()); + toggleButton.setEnabled(action.isEnabled() && action.isAvailable()); + toggleButton.setOnCheckedChangeListener( + (buttonView, isChecked) -> { + Intent intent = new Intent(); + intent.putExtra(QC_ACTION_TOGGLE_STATE, isChecked); + fireAction(action, intent); + }); + } + + @NonNull + private View createActionView(@NonNull ViewGroup root, @Nullable View actionView, + @LayoutRes int resId) { + if (actionView != null) { + // remove current action view + root.removeView(actionView); + } + actionView = mLayoutInflater.inflate(resId, /* root = */ null); + root.addView(actionView); + return actionView; + } + + private void initSlider(QCSlider slider) { + mSeekBar.setOnSeekBarChangeListener(null); + mSeekBar.setMin(slider.getMin()); + mSeekBar.setMax(slider.getMax()); + mSeekBar.setProgress(slider.getValue()); + if (mSeekbarChangeListener == null) { + mSeekbarChangeListener = new QCSeekbarChangeListener(); + } + mSeekbarChangeListener.setSlider(slider); + mSeekBar.setOnSeekBarChangeListener(mSeekbarChangeListener); + // set up rotary support + mSeekBarContainer.setOnKeyListener(mSeekBarKeyListener); + mSeekBarContainer.setOnFocusChangeListener(mSeekBarFocusChangeListener); + mSeekBarContainer.setOnGenericMotionListener(mSeekBarScrollListener); + } + + private void setInDirectManipulationMode(View view, SeekBar seekbar, boolean enable) { + mInDirectManipulationMode = enable; + DirectManipulationHelper.enableDirectManipulationMode(seekbar, enable); + view.setSelected(enable); + seekbar.setSelected(enable); + } + + private void fireAction(QCItem item, Intent intent) { + if (item.getPrimaryAction() != null) { + try { + item.getPrimaryAction().send(getContext(), 0, intent); + if (mActionListener != null) { + mActionListener.onQCAction(item); + } + } catch (PendingIntent.CanceledException e) { + Log.d(TAG, "Error sending intent", e); + } + } else if (item.getActionHandler() != null) { + item.getActionHandler().onAction(item, getContext(), intent); + if (mActionListener != null) { + mActionListener.onQCAction(item); + } + } + } + + private class QCSeekbarChangeListener implements SeekBar.OnSeekBarChangeListener { + // Interval of updates (in ms) sent in response to seekbar moving. + private static final int SLIDER_UPDATE_INTERVAL = 200; + + private final Handler mSliderUpdateHandler; + private QCSlider mSlider; + private int mCurrSliderValue; + private boolean mSliderUpdaterRunning; + private long mLastSentSliderUpdate; + private final Runnable mSliderUpdater = () -> { + sendSliderValue(); + mSliderUpdaterRunning = false; + }; + + QCSeekbarChangeListener() { + mSliderUpdateHandler = new Handler(); + } + + void setSlider(QCSlider slider) { + mSlider = slider; + } + + @Override + public void onProgressChanged(SeekBar seekBar, int progress, boolean fromUser) { + mCurrSliderValue = progress; + long now = System.currentTimeMillis(); + if (mLastSentSliderUpdate != 0 + && now - mLastSentSliderUpdate > SLIDER_UPDATE_INTERVAL) { + mSliderUpdaterRunning = false; + mSliderUpdateHandler.removeCallbacks(mSliderUpdater); + sendSliderValue(); + } else if (!mSliderUpdaterRunning) { + mSliderUpdaterRunning = true; + mSliderUpdateHandler.postDelayed(mSliderUpdater, SLIDER_UPDATE_INTERVAL); + } + } + + @Override + public void onStartTrackingTouch(SeekBar seekBar) { + } + + @Override + public void onStopTrackingTouch(SeekBar seekBar) { + if (mSliderUpdaterRunning) { + mSliderUpdaterRunning = false; + mSliderUpdateHandler.removeCallbacks(mSliderUpdater); + } + mCurrSliderValue = seekBar.getProgress(); + sendSliderValue(); + } + + private void sendSliderValue() { + if (mSlider == null) { + return; + } + mLastSentSliderUpdate = System.currentTimeMillis(); + Intent intent = new Intent(); + intent.putExtra(QC_ACTION_SLIDER_VALUE, mCurrSliderValue); + fireAction(mSlider, intent); + } + } +} diff --git a/car-qc-lib/src/com/android/car/qc/view/QCTileView.java b/car-qc-lib/src/com/android/car/qc/view/QCTileView.java new file mode 100644 index 0000000..233c81a --- /dev/null +++ b/car-qc-lib/src/com/android/car/qc/view/QCTileView.java @@ -0,0 +1,129 @@ +/* + * Copyright (C) 2021 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.qc.view; + +import static com.android.car.qc.QCItem.QC_ACTION_TOGGLE_STATE; +import static com.android.car.qc.view.QCView.QCActionListener; + +import android.app.PendingIntent; +import android.content.Context; +import android.content.Intent; +import android.graphics.drawable.Drawable; +import android.util.AttributeSet; +import android.util.Log; +import android.view.View; +import android.widget.FrameLayout; +import android.widget.TextView; + +import androidx.lifecycle.Observer; + +import com.android.car.qc.QCItem; +import com.android.car.qc.QCTile; +import com.android.car.qc.R; +import com.android.car.ui.uxr.DrawableStateToggleButton; + +/** + * Quick Controls view for {@link QCTile} instances. + */ +public class QCTileView extends FrameLayout implements Observer<QCItem> { + private static final String TAG = "QCTileView"; + + private View mTileWrapper; + private DrawableStateToggleButton mToggleButton; + private TextView mSubtitle; + private QCActionListener mActionListener; + + public QCTileView(Context context) { + super(context); + init(context); + } + + public QCTileView(Context context, AttributeSet attrs) { + super(context, attrs); + init(context); + } + + public QCTileView(Context context, AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + init(context); + } + + public QCTileView(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) { + super(context, attrs, defStyleAttr, defStyleRes); + init(context); + } + + /** + * Set the tile's {@link QCActionListener}. + */ + public void setActionListener(QCActionListener listener) { + mActionListener = listener; + } + + private void init(Context context) { + View.inflate(context, R.layout.qc_tile_view, /* root= */ this); + mTileWrapper = findViewById(R.id.qc_tile_wrapper); + mToggleButton = findViewById(R.id.qc_tile_toggle_button); + mSubtitle = findViewById(android.R.id.summary); + mToggleButton.setText(null); + mToggleButton.setTextOn(null); + mToggleButton.setTextOff(null); + + } + + @Override + public void onChanged(QCItem qcItem) { + if (qcItem == null) { + removeAllViews(); + return; + } + if (!qcItem.getType().equals(QCItem.QC_TYPE_TILE)) { + throw new IllegalArgumentException("Expected QCTile type for QCTileView but got " + + qcItem.getType()); + } + QCTile qcTile = (QCTile) qcItem; + mSubtitle.setText(qcTile.getSubtitle()); + mToggleButton.setOnCheckedChangeListener(null); + mToggleButton.setChecked(qcTile.isChecked()); + mToggleButton.setEnabled(qcTile.isEnabled()); + mTileWrapper.setEnabled(qcTile.isEnabled() && qcTile.isAvailable()); + mTileWrapper.setOnClickListener(v -> mToggleButton.toggle()); + Drawable icon = QCViewUtils.getInstance(mContext).getToggleIcon( + qcTile.getIcon(), qcTile.isAvailable()); + mToggleButton.setButtonDrawable(icon); + mToggleButton.setOnCheckedChangeListener( + (buttonView, isChecked) -> { + Intent intent = new Intent(); + intent.putExtra(QC_ACTION_TOGGLE_STATE, isChecked); + if (qcTile.getPrimaryAction() != null) { + try { + qcTile.getPrimaryAction().send(getContext(), 0, intent); + if (mActionListener != null) { + mActionListener.onQCAction(qcTile); + } + } catch (PendingIntent.CanceledException e) { + Log.d(TAG, "Error sending intent", e); + } + } else if (qcTile.getActionHandler() != null) { + qcTile.getActionHandler().onAction(qcTile, getContext(), intent); + if (mActionListener != null) { + mActionListener.onQCAction(qcTile); + } + } + }); + } +} diff --git a/car-qc-lib/src/com/android/car/qc/view/QCView.java b/car-qc-lib/src/com/android/car/qc/view/QCView.java new file mode 100644 index 0000000..73b909e --- /dev/null +++ b/car-qc-lib/src/com/android/car/qc/view/QCView.java @@ -0,0 +1,118 @@ +/* + * Copyright (C) 2021 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.qc.view; + +import android.content.Context; +import android.util.AttributeSet; +import android.view.Gravity; +import android.widget.FrameLayout; + +import androidx.annotation.NonNull; +import androidx.lifecycle.Observer; + +import com.android.car.qc.QCItem; + +/** + * Base Quick Controls View - supports {@link QCItem.QC_TYPE_TILE} and {@link QCItem.QC_TYPE_LIST} + */ +public class QCView extends FrameLayout implements Observer<QCItem> { + @QCItem.QCItemType + private String mType; + private Observer<QCItem> mChildObserver; + private QCActionListener mActionListener; + + public QCView(Context context) { + super(context); + } + + public QCView(Context context, AttributeSet attrs) { + super(context, attrs); + } + + public QCView(Context context, AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + } + + public QCView(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) { + super(context, attrs, defStyleAttr, defStyleRes); + } + + /** + * Set the view's {@link QCActionListener}. This listener will propagate to all sub-views. + */ + public void setActionListener(QCActionListener listener) { + mActionListener = listener; + if (mChildObserver instanceof QCTileView) { + ((QCTileView) mChildObserver).setActionListener(mActionListener); + } else if (mChildObserver instanceof QCListView) { + ((QCListView) mChildObserver).setActionListener(mActionListener); + } + } + + @Override + public void onChanged(QCItem qcItem) { + if (qcItem == null) { + removeAllViews(); + mChildObserver = null; + mType = null; + return; + } + if (!isValidQCItemType(qcItem)) { + throw new IllegalArgumentException("Expected QCTile or QCList type but got " + + qcItem.getType()); + } + if (qcItem.getType().equals(mType)) { + mChildObserver.onChanged(qcItem); + return; + } + removeAllViews(); + mType = qcItem.getType(); + if (mType.equals(QCItem.QC_TYPE_TILE)) { + QCTileView view = new QCTileView(getContext()); + FrameLayout.LayoutParams params = new FrameLayout.LayoutParams( + LayoutParams.WRAP_CONTENT, + LayoutParams.WRAP_CONTENT, + Gravity.CENTER_HORIZONTAL); + view.onChanged(qcItem); + view.setActionListener(mActionListener); + addView(view, params); + mChildObserver = view; + } else { + QCListView view = new QCListView(getContext()); + view.onChanged(qcItem); + view.setActionListener(mActionListener); + addView(view); + mChildObserver = view; + } + } + + private boolean isValidQCItemType(QCItem qcItem) { + String type = qcItem.getType(); + return type.equals(QCItem.QC_TYPE_TILE) || type.equals(QCItem.QC_TYPE_LIST); + } + + /** + * Listener to be called when an action occurs on a QCView. + */ + public interface QCActionListener { + /** + * Called when an interaction has occurred with an element in this view. + * @param item the specific item within the {@link QCItem} that was interacted with. + */ + void onQCAction(@NonNull QCItem item); + } +} diff --git a/car-qc-lib/src/com/android/car/qc/view/QCViewUtils.java b/car-qc-lib/src/com/android/car/qc/view/QCViewUtils.java new file mode 100644 index 0000000..366c724 --- /dev/null +++ b/car-qc-lib/src/com/android/car/qc/view/QCViewUtils.java @@ -0,0 +1,95 @@ +/* + * Copyright (C) 2021 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.qc.view; + +import android.annotation.ColorInt; +import android.content.Context; +import android.content.res.ColorStateList; +import android.graphics.drawable.Drawable; +import android.graphics.drawable.Icon; +import android.graphics.drawable.LayerDrawable; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.android.car.qc.R; + +/** + * Utility class used by {@link QCTileView} and {@link QCRowView} + */ +public class QCViewUtils { + private static QCViewUtils sInstance; + + private final Context mContext; + private final Drawable mDefaultToggleBackground; + private final Drawable mUnavailableToggleBackground; + private final ColorStateList mDefaultToggleIconTint; + @ColorInt + private final int mUnavailableToggleIconTint; + private final int mToggleForegroundIconInset; + + private QCViewUtils(@NonNull Context context) { + mContext = context.getApplicationContext(); + mDefaultToggleBackground = mContext.getDrawable(R.drawable.qc_toggle_background); + mUnavailableToggleBackground = mContext.getDrawable( + R.drawable.qc_toggle_unavailable_background); + mDefaultToggleIconTint = mContext.getColorStateList(R.color.qc_toggle_icon_fill_color); + mUnavailableToggleIconTint = mContext.getColor(R.color.qc_toggle_unavailable_color); + mToggleForegroundIconInset = mContext.getResources() + .getDimensionPixelSize(R.dimen.qc_toggle_foreground_icon_inset); + } + + /** + * Get an instance of {@link QCViewUtils} + */ + public static QCViewUtils getInstance(@NonNull Context context) { + if (sInstance == null) { + sInstance = new QCViewUtils(context); + } + return sInstance; + } + + /** + * Create a return a Quick Control toggle icon - used for tiles and action toggles. + */ + public Drawable getToggleIcon(@Nullable Icon icon, boolean available) { + Drawable background = available + ? mDefaultToggleBackground.getConstantState().newDrawable().mutate() + : mUnavailableToggleBackground.getConstantState().newDrawable().mutate(); + if (icon == null) { + return background; + } + + Drawable iconDrawable = icon.loadDrawable(mContext); + if (iconDrawable == null) { + return background; + } + + if (!available) { + iconDrawable.setTint(mUnavailableToggleIconTint); + } else { + iconDrawable.setTintList(mDefaultToggleIconTint); + } + + Drawable[] layers = {background, iconDrawable}; + LayerDrawable drawable = new LayerDrawable(layers); + drawable.setLayerInsetRelative(/* index= */ 1, mToggleForegroundIconInset, + mToggleForegroundIconInset, mToggleForegroundIconInset, + mToggleForegroundIconInset); + return drawable; + } +} diff --git a/car-qc-lib/tests/unit/Android.bp b/car-qc-lib/tests/unit/Android.bp new file mode 100644 index 0000000..b1f107a --- /dev/null +++ b/car-qc-lib/tests/unit/Android.bp @@ -0,0 +1,47 @@ +// +// Copyright (C) 2021 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 { + default_applicable_licenses: ["Android-Apache-2.0"], +} + +android_test { + name: "CarQCLibUnitTests", + + certificate: "platform", + privileged: true, + + srcs: ["src/**/*.java"], + + libs: [ + "android.test.runner", + "android.test.base", + "android.test.mock", + ], + + static_libs: [ + "car-qc-lib", + "androidx.test.core", + "androidx.test.rules", + "androidx.test.ext.junit", + "androidx.test.ext.truth", + "mockito-target-extended-minus-junit4", + "platform-test-annotations", + "truth-prebuilt", + "testng", + ], + + jni_libs: ["libdexmakerjvmtiagent", "libstaticjvmtiagent"], +} diff --git a/car-qc-lib/tests/unit/AndroidManifest.xml b/car-qc-lib/tests/unit/AndroidManifest.xml new file mode 100644 index 0000000..e500c4d --- /dev/null +++ b/car-qc-lib/tests/unit/AndroidManifest.xml @@ -0,0 +1,41 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + ~ Copyright (C) 2021 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.qc.tests.unit"> + + <application android:debuggable="true"> + <uses-library android:name="android.test.runner" /> + + <provider + android:name="com.android.car.qc.testutils.AllowedTestQCProvider" + android:authorities="com.android.car.qc.testutils.AllowedTestQCProvider" + android:exported="true"> + </provider> + + <provider + android:name="com.android.car.qc.testutils.DeniedTestQCProvider" + android:authorities="com.android.car.qc.testutils.DeniedTestQCProvider" + android:exported="true"> + </provider> + </application> + + <instrumentation android:name="androidx.test.runner.AndroidJUnitRunner" + android:targetPackage="com.android.car.qc.tests.unit" + android:label="Quick Controls Library Unit Tests"/> +</manifest> diff --git a/car-qc-lib/tests/unit/src/com/android/car/qc/QCActionItemTest.java b/car-qc-lib/tests/unit/src/com/android/car/qc/QCActionItemTest.java new file mode 100644 index 0000000..588144f --- /dev/null +++ b/car-qc-lib/tests/unit/src/com/android/car/qc/QCActionItemTest.java @@ -0,0 +1,101 @@ +/* + * Copyright (C) 2021 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.qc; + +import static com.android.car.qc.QCItem.QC_TYPE_ACTION_SWITCH; +import static com.android.car.qc.QCItem.QC_TYPE_ACTION_TOGGLE; + +import static com.google.common.truth.Truth.assertThat; + +import static org.testng.Assert.assertThrows; + +import android.app.PendingIntent; +import android.graphics.drawable.Icon; + +import androidx.test.ext.junit.runners.AndroidJUnit4; + +import org.junit.Test; +import org.junit.runner.RunWith; + +@RunWith(AndroidJUnit4.class) +public class QCActionItemTest extends QCItemTestCase<QCActionItem> { + + @Test + public void onCreate_invalidType_throwsException() { + assertThrows(IllegalArgumentException.class, + () -> createAction("INVALID_TYPE", /* action= */ null, /* icon= */ null)); + } + + @Test + public void onCreateSwitch_hasCorrectType() { + QCActionItem action = createAction(QC_TYPE_ACTION_SWITCH, /* action= */ null, + /* icon= */null); + assertThat(action.getType()).isEqualTo(QC_TYPE_ACTION_SWITCH); + } + + @Test + public void onCreateToggle_hasCorrectType() { + QCActionItem action = createAction(QC_TYPE_ACTION_TOGGLE, /* action= */ null, + /* icon= */ null); + assertThat(action.getType()).isEqualTo(QC_TYPE_ACTION_TOGGLE); + } + + @Test + public void onBundle_nullAction_noCrash() { + QCActionItem action = createAction(QC_TYPE_ACTION_TOGGLE, /* action= */ null, mDefaultIcon); + writeAndLoadFromBundle(action); + // Test passes if this doesn't crash + } + + @Test + public void onBundle_nullIcon_noCrash() { + QCActionItem action = createAction(QC_TYPE_ACTION_TOGGLE, mDefaultAction, /* icon= */ null); + writeAndLoadFromBundle(action); + // Test passes if this doesn't crash + } + + @Test + public void onBundle_switch_accurateData() { + QCActionItem action = createAction(QC_TYPE_ACTION_SWITCH, mDefaultAction, /* icon= */ null); + QCActionItem newAction = writeAndLoadFromBundle(action); + assertThat(newAction.getType()).isEqualTo(QC_TYPE_ACTION_SWITCH); + assertThat(newAction.isChecked()).isTrue(); + assertThat(newAction.isEnabled()).isTrue(); + assertThat(newAction.getPrimaryAction()).isNotNull(); + assertThat(newAction.getIcon()).isNull(); + } + + @Test + public void onBundle_toggle_accurateDate() { + QCActionItem action = createAction(QC_TYPE_ACTION_TOGGLE, mDefaultAction, mDefaultIcon); + QCActionItem newAction = writeAndLoadFromBundle(action); + assertThat(newAction.getType()).isEqualTo(QC_TYPE_ACTION_TOGGLE); + assertThat(newAction.isChecked()).isTrue(); + assertThat(newAction.isEnabled()).isTrue(); + assertThat(newAction.getPrimaryAction()).isNotNull(); + assertThat(newAction.getIcon()).isNotNull(); + } + + private QCActionItem createAction(String type, PendingIntent action, Icon icon) { + return new QCActionItem.Builder(type) + .setChecked(true) + .setEnabled(true) + .setAction(action) + .setIcon(icon) + .build(); + } +} diff --git a/car-qc-lib/tests/unit/src/com/android/car/qc/QCItemTestCase.java b/car-qc-lib/tests/unit/src/com/android/car/qc/QCItemTestCase.java new file mode 100644 index 0000000..fab8302 --- /dev/null +++ b/car-qc-lib/tests/unit/src/com/android/car/qc/QCItemTestCase.java @@ -0,0 +1,44 @@ +/* + * Copyright (C) 2021 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.qc; + +import android.R; +import android.app.PendingIntent; +import android.content.Context; +import android.content.Intent; +import android.graphics.drawable.Icon; +import android.os.Bundle; + +import androidx.test.core.app.ApplicationProvider; + +public abstract class QCItemTestCase<T extends QCItem> { + protected static final String BUNDLE_KEY = "BUNDLE_KEY"; + protected static final String TEST_TITLE = "TEST TITLE"; + protected static final String TEST_SUBTITLE = "TEST SUBTITLE"; + + protected final Context mContext = ApplicationProvider.getApplicationContext(); + + protected PendingIntent mDefaultAction = PendingIntent.getActivity(mContext, + /* requestCode= */ 0, new Intent(), PendingIntent.FLAG_IMMUTABLE); + protected Icon mDefaultIcon = Icon.createWithResource(mContext, R.drawable.btn_star); + + protected T writeAndLoadFromBundle(T item) { + Bundle bundle = new Bundle(); + bundle.putParcelable(BUNDLE_KEY, item); + return bundle.getParcelable(BUNDLE_KEY); + } +} diff --git a/car-qc-lib/tests/unit/src/com/android/car/qc/QCListTest.java b/car-qc-lib/tests/unit/src/com/android/car/qc/QCListTest.java new file mode 100644 index 0000000..766d82c --- /dev/null +++ b/car-qc-lib/tests/unit/src/com/android/car/qc/QCListTest.java @@ -0,0 +1,62 @@ +/* + * Copyright (C) 2021 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.qc; + +import static com.android.car.qc.QCItem.QC_TYPE_LIST; + +import static com.google.common.truth.Truth.assertThat; + +import androidx.test.ext.junit.runners.AndroidJUnit4; + +import org.junit.Test; +import org.junit.runner.RunWith; + +import java.util.Collections; +import java.util.List; + +@RunWith(AndroidJUnit4.class) +public class QCListTest extends QCItemTestCase<QCList> { + + @Test + public void onCreate_hasCorrectType() { + QCList list = createList(Collections.emptyList()); + assertThat(list.getType()).isEqualTo(QC_TYPE_LIST); + } + + @Test + public void createFromParcel_accurateData() { + QCRow row = new QCRow.Builder() + .setTitle(TEST_TITLE) + .setSubtitle(TEST_SUBTITLE) + .setIcon(mDefaultIcon) + .setPrimaryAction(mDefaultAction) + .build(); + + QCList list = createList(Collections.singletonList(row)); + QCList newList = writeAndLoadFromBundle(list); + assertThat(newList.getType()).isEqualTo(QC_TYPE_LIST); + assertThat(newList.getRows().size()).isEqualTo(1); + } + + private QCList createList(List<QCRow> rows) { + QCList.Builder builder = new QCList.Builder(); + for (QCRow row : rows) { + builder.addRow(row); + } + return builder.build(); + } +} diff --git a/car-qc-lib/tests/unit/src/com/android/car/qc/QCRowTest.java b/car-qc-lib/tests/unit/src/com/android/car/qc/QCRowTest.java new file mode 100644 index 0000000..816c1d6 --- /dev/null +++ b/car-qc-lib/tests/unit/src/com/android/car/qc/QCRowTest.java @@ -0,0 +1,122 @@ +/* + * Copyright (C) 2021 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.qc; + +import static com.android.car.qc.QCItem.QC_TYPE_ACTION_SWITCH; +import static com.android.car.qc.QCItem.QC_TYPE_ROW; + +import static com.google.common.truth.Truth.assertThat; + +import android.app.PendingIntent; +import android.graphics.drawable.Icon; + +import androidx.test.ext.junit.runners.AndroidJUnit4; + +import org.junit.Test; +import org.junit.runner.RunWith; + +import java.util.Collections; +import java.util.List; + +@RunWith(AndroidJUnit4.class) +public class QCRowTest extends QCItemTestCase<QCRow> { + + @Test + public void onCreate_hasCorrectType() { + QCRow row = createRow(/* action= */ null, /* icon= */ null); + assertThat(row.getType()).isEqualTo(QC_TYPE_ROW); + } + + @Test + public void onBundle_nullAction_noCrash() { + QCRow row = createRow(/* action= */ null, mDefaultIcon); + writeAndLoadFromBundle(row); + // Test passes if this doesn't crash + } + + @Test + public void onBundle_nullIcon_noCrash() { + QCRow row = createRow(mDefaultAction, /* icon= */ null); + writeAndLoadFromBundle(row); + // Test passes if this doesn't crash + } + + @Test + public void createFromParcel_accurateData() { + QCRow row = createRow(mDefaultAction, mDefaultIcon); + QCRow newRow = writeAndLoadFromBundle(row); + assertThat(newRow.getType()).isEqualTo(QC_TYPE_ROW); + assertThat(newRow.getTitle()).isEqualTo(TEST_TITLE); + assertThat(newRow.getSubtitle()).isEqualTo(TEST_SUBTITLE); + assertThat(newRow.getPrimaryAction()).isNotNull(); + assertThat(newRow.getStartIcon()).isNotNull(); + } + + @Test + public void createFromParcel_accurateData_startItem() { + QCActionItem item = new QCActionItem.Builder(QC_TYPE_ACTION_SWITCH).build(); + + QCRow row = createRow(/* action= */ null, /* icon= */ null, Collections.singletonList(item), + Collections.emptyList(), Collections.emptyList()); + QCRow newRow = writeAndLoadFromBundle(row); + assertThat(newRow.getStartItems().size()).isEqualTo(1); + } + + @Test + public void createFromParcel_accurateData_endItem() { + QCActionItem item = new QCActionItem.Builder(QC_TYPE_ACTION_SWITCH).build(); + + QCRow row = createRow(/* action= */ null, /* icon= */ null, Collections.emptyList(), + Collections.singletonList(item), Collections.emptyList()); + QCRow newRow = writeAndLoadFromBundle(row); + assertThat(newRow.getEndItems().size()).isEqualTo(1); + } + + @Test + public void createFromParcel_accurateData_slider() { + QCSlider slider = new QCSlider.Builder().build(); + + QCRow row = createRow(/* action= */ null, /* icon= */ null, Collections.emptyList(), + Collections.emptyList(), Collections.singletonList(slider)); + QCRow newRow = writeAndLoadFromBundle(row); + assertThat(newRow.getSlider()).isNotNull(); + } + + private QCRow createRow(PendingIntent action, Icon icon) { + return createRow(action, icon, Collections.emptyList(), Collections.emptyList(), + Collections.emptyList()); + } + + private QCRow createRow(PendingIntent action, Icon icon, List<QCActionItem> startItems, + List<QCActionItem> endItems, List<QCSlider> sliders) { + QCRow.Builder builder = new QCRow.Builder() + .setTitle(TEST_TITLE) + .setSubtitle(TEST_SUBTITLE) + .setIcon(icon) + .setPrimaryAction(action); + for (QCActionItem item : startItems) { + builder.addStartItem(item); + } + for (QCActionItem item : endItems) { + builder.addEndItem(item); + } + for (QCSlider slider : sliders) { + builder.addSlider(slider); + } + return builder.build(); + } +} diff --git a/car-qc-lib/tests/unit/src/com/android/car/qc/QCSliderTest.java b/car-qc-lib/tests/unit/src/com/android/car/qc/QCSliderTest.java new file mode 100644 index 0000000..97bf191 --- /dev/null +++ b/car-qc-lib/tests/unit/src/com/android/car/qc/QCSliderTest.java @@ -0,0 +1,68 @@ +/* + * Copyright (C) 2021 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.qc; + +import static com.android.car.qc.QCItem.QC_TYPE_SLIDER; + +import static com.google.common.truth.Truth.assertThat; + +import android.app.PendingIntent; + +import androidx.test.ext.junit.runners.AndroidJUnit4; + +import org.junit.Test; +import org.junit.runner.RunWith; + +@RunWith(AndroidJUnit4.class) +public class QCSliderTest extends QCItemTestCase<QCSlider> { + private static final int MIN = 50; + private static final int MAX = 150; + private static final int VALUE = 75; + + @Test + public void onCreate_hasCorrectType() { + QCSlider slider = createSlider(/* action= */ null); + assertThat(slider.getType()).isEqualTo(QC_TYPE_SLIDER); + } + + @Test + public void onBundle_nullAction_noCrash() { + QCSlider slider = createSlider(/* action= */ null); + writeAndLoadFromBundle(slider); + // Test passes if this doesn't crash + } + + @Test + public void createFromParcel_accurateData() { + QCSlider slider = createSlider(mDefaultAction); + QCSlider newSlider = writeAndLoadFromBundle(slider); + assertThat(newSlider.getType()).isEqualTo(QC_TYPE_SLIDER); + assertThat(newSlider.getPrimaryAction()).isNotNull(); + assertThat(newSlider.getMin()).isEqualTo(MIN); + assertThat(newSlider.getMax()).isEqualTo(MAX); + assertThat(newSlider.getValue()).isEqualTo(VALUE); + } + + private QCSlider createSlider(PendingIntent action) { + return new QCSlider.Builder() + .setMin(MIN) + .setMax(MAX) + .setValue(VALUE) + .setInputAction(action) + .build(); + } +} diff --git a/car-qc-lib/tests/unit/src/com/android/car/qc/QCTileTest.java b/car-qc-lib/tests/unit/src/com/android/car/qc/QCTileTest.java new file mode 100644 index 0000000..e9530d5 --- /dev/null +++ b/car-qc-lib/tests/unit/src/com/android/car/qc/QCTileTest.java @@ -0,0 +1,75 @@ +/* + * Copyright (C) 2021 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.qc; + +import static com.android.car.qc.QCItem.QC_TYPE_TILE; + +import static com.google.common.truth.Truth.assertThat; + +import android.app.PendingIntent; +import android.graphics.drawable.Icon; + +import androidx.test.ext.junit.runners.AndroidJUnit4; + +import org.junit.Test; +import org.junit.runner.RunWith; + +@RunWith(AndroidJUnit4.class) +public class QCTileTest extends QCItemTestCase<QCTile> { + + @Test + public void onCreate_hasCorrectType() { + QCTile tile = createTile(/* action= */ null, /* icon= */ null); + assertThat(tile.getType()).isEqualTo(QC_TYPE_TILE); + } + + @Test + public void onBundle_nullAction_noCrash() { + QCTile tile = createTile(/* action= */ null, mDefaultIcon); + writeAndLoadFromBundle(tile); + // Test passes if this doesn't crash + } + + @Test + public void onBundle_nullIcon_noCrash() { + QCTile tile = createTile(mDefaultAction, /* icon= */ null); + writeAndLoadFromBundle(tile); + // Test passes if this doesn't crash + } + + @Test + public void createFromParcel_accurateData() { + QCTile tile = createTile(mDefaultAction, mDefaultIcon); + QCTile newTile = writeAndLoadFromBundle(tile); + assertThat(newTile.getType()).isEqualTo(QC_TYPE_TILE); + assertThat(newTile.getSubtitle()).isEqualTo(TEST_SUBTITLE); + assertThat(newTile.isChecked()).isTrue(); + assertThat(newTile.isEnabled()).isTrue(); + assertThat(newTile.getPrimaryAction()).isNotNull(); + assertThat(newTile.getIcon()).isNotNull(); + } + + private QCTile createTile(PendingIntent action, Icon icon) { + return new QCTile.Builder() + .setSubtitle(TEST_SUBTITLE) + .setChecked(true) + .setEnabled(true) + .setAction(action) + .setIcon(icon) + .build(); + } +} diff --git a/car-qc-lib/tests/unit/src/com/android/car/qc/controller/BaseQCControllerTestCase.java b/car-qc-lib/tests/unit/src/com/android/car/qc/controller/BaseQCControllerTestCase.java new file mode 100644 index 0000000..095b192 --- /dev/null +++ b/car-qc-lib/tests/unit/src/com/android/car/qc/controller/BaseQCControllerTestCase.java @@ -0,0 +1,82 @@ +/* + * Copyright (C) 2021 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.qc.controller; + +import static com.google.common.truth.Truth.assertThat; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.verify; + +import android.content.Context; + +import androidx.lifecycle.Observer; +import androidx.test.core.app.ApplicationProvider; + +import com.android.car.qc.QCItem; +import com.android.car.qc.QCTile; + +import org.junit.Test; + +public abstract class BaseQCControllerTestCase<T extends BaseQCController> { + + protected final Context mContext = spy(ApplicationProvider.getApplicationContext()); + + protected abstract T getController(); + + @Test + public void listen_updateListeningCalled() { + T spiedController = spy(getController()); + spiedController.listen(true); + verify(spiedController).updateListening(); + } + + @Test + public void addObserver_updateListeningCalled() { + Observer<QCItem> observer = mock(Observer.class); + T spiedController = spy(getController()); + spiedController.addObserver(observer); + verify(spiedController).updateListening(); + } + + @Test + public void removeObserver_updateListeningCalled() { + Observer<QCItem> observer = mock(Observer.class); + T spiedController = spy(getController()); + spiedController.removeObserver(observer); + verify(spiedController).updateListening(); + } + + @Test + public void onQCItemUpdated_observersNotified() { + Observer<QCItem> observer = mock(Observer.class); + getController().addObserver(observer); + getController().onQCItemUpdated(new QCTile.Builder().build()); + verify(observer).onChanged(any(QCItem.class)); + } + + @Test + public void onDestroy_cleanUpController() { + Observer<QCItem> observer = mock(Observer.class); + getController().addObserver(observer); + getController().listen(true); + getController().destroy(); + assertThat(getController().mObservers.size()).isEqualTo(0); + assertThat(getController().mShouldListen).isFalse(); + } +} diff --git a/car-qc-lib/tests/unit/src/com/android/car/qc/controller/LocalQCControllerTest.java b/car-qc-lib/tests/unit/src/com/android/car/qc/controller/LocalQCControllerTest.java new file mode 100644 index 0000000..17d7392 --- /dev/null +++ b/car-qc-lib/tests/unit/src/com/android/car/qc/controller/LocalQCControllerTest.java @@ -0,0 +1,81 @@ +/* + * Copyright (C) 2021 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.qc.controller; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.verify; + +import androidx.lifecycle.Observer; +import androidx.test.ext.junit.runners.AndroidJUnit4; + +import com.android.car.qc.QCItem; +import com.android.car.qc.provider.BaseLocalQCProvider; + +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.Mockito; + +@RunWith(AndroidJUnit4.class) +public class LocalQCControllerTest extends BaseQCControllerTestCase<LocalQCController> { + + private LocalQCController mController; + private BaseLocalQCProvider mProvider; + + @Override + protected LocalQCController getController() { + if (mController == null) { + mProvider = mock(BaseLocalQCProvider.class); + mController = new LocalQCController(mContext, mProvider); + } + return mController; + } + + @Test + public void onCreate_setsProviderNotifier() { + getController(); // instantiate + verify(mProvider).setNotifier(any()); + } + + @Test + public void updateListening_updatesProviderListening() { + Observer<QCItem> observer = mock(Observer.class); + getController().addObserver(observer); + getController().listen(true); + verify(mProvider).shouldListen(true); + getController().listen(false); + verify(mProvider).shouldListen(false); + } + + @Test + public void updateListening_listen_updatesQCItem() { + Observer<QCItem> observer = mock(Observer.class); + LocalQCController spiedController = spy(getController()); + spiedController.addObserver(observer); + Mockito.reset(mProvider); + spiedController.listen(true); + verify(mProvider).getQCItem(); + verify(spiedController).onQCItemUpdated(any()); + } + + @Test + public void onDestroy_callsProviderDestroy() { + getController().destroy(); + verify(mProvider).onDestroy(); + } +} diff --git a/car-qc-lib/tests/unit/src/com/android/car/qc/controller/RemoteQCControllerTest.java b/car-qc-lib/tests/unit/src/com/android/car/qc/controller/RemoteQCControllerTest.java new file mode 100644 index 0000000..a1db602 --- /dev/null +++ b/car-qc-lib/tests/unit/src/com/android/car/qc/controller/RemoteQCControllerTest.java @@ -0,0 +1,153 @@ +/* + * Copyright (C) 2021 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.qc.controller; + +import static com.android.car.qc.provider.BaseQCProvider.EXTRA_URI; +import static com.android.car.qc.testutils.TestQCProvider.IS_DESTROYED_KEY; +import static com.android.car.qc.testutils.TestQCProvider.IS_SUBSCRIBED_KEY; +import static com.android.car.qc.testutils.TestQCProvider.KEY_DEFAULT; +import static com.android.car.qc.testutils.TestQCProvider.METHOD_IS_DESTROYED; +import static com.android.car.qc.testutils.TestQCProvider.METHOD_IS_SUBSCRIBED; + +import static com.google.common.truth.Truth.assertThat; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.notNull; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import android.content.ContentProviderClient; +import android.content.ContentResolver; +import android.database.ContentObserver; +import android.net.Uri; +import android.os.Bundle; +import android.os.RemoteException; + +import androidx.lifecycle.Observer; +import androidx.test.ext.junit.runners.AndroidJUnit4; +import androidx.test.platform.app.InstrumentationRegistry; + +import com.android.car.qc.QCItem; + +import org.junit.Test; +import org.junit.runner.RunWith; + +@RunWith(AndroidJUnit4.class) +public class RemoteQCControllerTest extends BaseQCControllerTestCase<RemoteQCController> { + + private final Uri mDefaultUri = Uri.parse( + "content://com.android.car.qc.testutils.AllowedTestQCProvider/" + KEY_DEFAULT); + + private RemoteQCController mController; + + @Override + protected RemoteQCController getController() { + if (mController == null) { + mController = new RemoteQCController(mContext, mDefaultUri, mContext.getMainExecutor()); + } + return mController; + } + + @Test + public void updateListening_listen_updatesQCItem() { + Observer<QCItem> observer = mock(Observer.class); + RemoteQCController spiedController = spy(getController()); + spiedController.addObserver(observer); + spiedController.listen(true); + InstrumentationRegistry.getInstrumentation().waitForIdleSync(); + verify(spiedController).onQCItemUpdated(notNull()); + } + + @Test + public void updateListening_listen_providerSubscribed() throws RemoteException { + Observer<QCItem> observer = mock(Observer.class); + getController().addObserver(observer); + getController().listen(true); + InstrumentationRegistry.getInstrumentation().waitForIdleSync(); + + Bundle extras = new Bundle(); + extras.putParcelable(EXTRA_URI, mDefaultUri); + Bundle res = getController().getClient().call(METHOD_IS_SUBSCRIBED, null, extras); + assertThat(res).isNotNull(); + boolean isSubscribed = res.getBoolean(IS_SUBSCRIBED_KEY, false); + assertThat(isSubscribed).isTrue(); + } + + @Test + public void updateListening_doNotListen_providerUnsubscribed() throws RemoteException { + Observer<QCItem> observer = mock(Observer.class); + getController().addObserver(observer); + getController().listen(true); + getController().listen(false); + InstrumentationRegistry.getInstrumentation().waitForIdleSync(); + + Bundle extras = new Bundle(); + extras.putParcelable(EXTRA_URI, mDefaultUri); + Bundle res = getController().getClient().call(METHOD_IS_SUBSCRIBED, null, extras); + assertThat(res).isNotNull(); + boolean isSubscribed = res.getBoolean(IS_SUBSCRIBED_KEY, true); + assertThat(isSubscribed).isFalse(); + } + + @Test + public void updateListening_listen_registerContentObserver() { + ContentResolver resolver = mock(ContentResolver.class); + when(mContext.getContentResolver()).thenReturn(resolver); + when(resolver.acquireContentProviderClient(mDefaultUri)).thenReturn( + mock(ContentProviderClient.class)); + Observer<QCItem> observer = mock(Observer.class); + getController().addObserver(observer); + getController().listen(true); + InstrumentationRegistry.getInstrumentation().waitForIdleSync(); + verify(resolver).registerContentObserver(eq(mDefaultUri), eq(true), + any(ContentObserver.class)); + } + + @Test + public void updateListening_doNotListen_unregisterContentObserver() { + ContentResolver resolver = mock(ContentResolver.class); + when(mContext.getContentResolver()).thenReturn(resolver); + when(resolver.acquireContentProviderClient(mDefaultUri)).thenReturn( + mock(ContentProviderClient.class)); + Observer<QCItem> observer = mock(Observer.class); + getController().addObserver(observer); + getController().listen(true); + getController().listen(false); + InstrumentationRegistry.getInstrumentation().waitForIdleSync(); + verify(resolver).unregisterContentObserver(any(ContentObserver.class)); + } + + @Test + public void onDestroy_callsProviderOnDestroy() throws RemoteException { + Observer<QCItem> observer = mock(Observer.class); + getController().addObserver(observer); + getController().listen(true); + getController().listen(false); + getController().destroy(); + InstrumentationRegistry.getInstrumentation().waitForIdleSync(); + + Bundle extras = new Bundle(); + extras.putParcelable(EXTRA_URI, mDefaultUri); + Bundle res = getController().getClient().call(METHOD_IS_DESTROYED, null, extras); + assertThat(res).isNotNull(); + boolean isDestroyed = res.getBoolean(IS_DESTROYED_KEY, false); + assertThat(isDestroyed).isTrue(); + } +} diff --git a/car-qc-lib/tests/unit/src/com/android/car/qc/provider/BaseLocalQCProviderTest.java b/car-qc-lib/tests/unit/src/com/android/car/qc/provider/BaseLocalQCProviderTest.java new file mode 100644 index 0000000..6defad7 --- /dev/null +++ b/car-qc-lib/tests/unit/src/com/android/car/qc/provider/BaseLocalQCProviderTest.java @@ -0,0 +1,102 @@ +/* + * Copyright (C) 2021 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.qc.provider; + +import static com.google.common.truth.Truth.assertThat; + +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; + +import android.content.Context; + +import androidx.test.core.app.ApplicationProvider; +import androidx.test.ext.junit.runners.AndroidJUnit4; + +import com.android.car.qc.QCItem; +import com.android.car.qc.QCTile; + +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; + +@RunWith(AndroidJUnit4.class) +public class BaseLocalQCProviderTest { + + private final Context mContext = ApplicationProvider.getApplicationContext(); + private TestBaseLocalQCProvider mProvider; + + @Before + public void setUp() { + mProvider = new TestBaseLocalQCProvider(mContext); + } + + @Test + public void getQCItem_returnsItem() { + QCItem item = mProvider.getQCItem(); + assertThat(item).isNotNull(); + assertThat(item instanceof QCTile).isTrue(); + } + + @Test + public void listen_callsOnSubscribed() { + mProvider.shouldListen(true); + assertThat(mProvider.isSubscribed()).isTrue(); + } + + @Test + public void stopListening_callsOnUnsubscribed() { + mProvider.shouldListen(true); + mProvider.shouldListen(false); + assertThat(mProvider.isSubscribed()).isFalse(); + } + + @Test + public void notifyChange_updateNotified() { + BaseLocalQCProvider.Notifier notifier = mock(BaseLocalQCProvider.Notifier.class); + mProvider.setNotifier(notifier); + mProvider.notifyChange(); + verify(notifier).notifyUpdate(); + } + + private static class TestBaseLocalQCProvider extends BaseLocalQCProvider { + + private boolean mIsSubscribed; + + TestBaseLocalQCProvider(Context context) { + super(context); + } + + @Override + public QCItem getQCItem() { + return new QCTile.Builder().build(); + } + + @Override + protected void onSubscribed() { + mIsSubscribed = true; + } + + @Override + protected void onUnsubscribed() { + mIsSubscribed = false; + } + + boolean isSubscribed() { + return mIsSubscribed; + } + } +} diff --git a/car-qc-lib/tests/unit/src/com/android/car/qc/provider/BaseQCProviderTest.java b/car-qc-lib/tests/unit/src/com/android/car/qc/provider/BaseQCProviderTest.java new file mode 100644 index 0000000..30607fa --- /dev/null +++ b/car-qc-lib/tests/unit/src/com/android/car/qc/provider/BaseQCProviderTest.java @@ -0,0 +1,153 @@ +/* + * Copyright (C) 2021 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.qc.provider; + +import static com.android.car.qc.provider.BaseQCProvider.EXTRA_ITEM; +import static com.android.car.qc.provider.BaseQCProvider.EXTRA_URI; +import static com.android.car.qc.provider.BaseQCProvider.METHOD_BIND; +import static com.android.car.qc.provider.BaseQCProvider.METHOD_DESTROY; +import static com.android.car.qc.provider.BaseQCProvider.METHOD_SUBSCRIBE; +import static com.android.car.qc.provider.BaseQCProvider.METHOD_UNSUBSCRIBE; +import static com.android.car.qc.testutils.TestQCProvider.IS_DESTROYED_KEY; +import static com.android.car.qc.testutils.TestQCProvider.IS_SUBSCRIBED_KEY; +import static com.android.car.qc.testutils.TestQCProvider.KEY_DEFAULT; +import static com.android.car.qc.testutils.TestQCProvider.KEY_SLOW; +import static com.android.car.qc.testutils.TestQCProvider.METHOD_IS_DESTROYED; +import static com.android.car.qc.testutils.TestQCProvider.METHOD_IS_SUBSCRIBED; + +import static com.google.common.truth.Truth.assertThat; + +import static org.testng.Assert.assertThrows; + +import android.content.ContentProviderClient; +import android.content.Context; +import android.net.Uri; +import android.os.Bundle; +import android.os.Parcelable; +import android.os.RemoteException; + +import androidx.test.core.app.ApplicationProvider; +import androidx.test.ext.junit.runners.AndroidJUnit4; + +import com.android.car.qc.QCItem; + +import org.junit.Test; +import org.junit.runner.RunWith; + +@RunWith(AndroidJUnit4.class) +public class BaseQCProviderTest { + + private final Context mContext = ApplicationProvider.getApplicationContext(); + private final Uri mDefaultUri = Uri.parse( + "content://com.android.car.qc.testutils.AllowedTestQCProvider/" + KEY_DEFAULT); + private final Uri mSlowUri = + Uri.parse("content://com.android.car.qc.testutils.AllowedTestQCProvider/" + KEY_SLOW); + private final Uri mDeniedUri = + Uri.parse("content://com.android.car.qc.testutils.DeniedTestQCProvider"); + + @Test + public void callOnBind_allowed_returnsItem() throws RemoteException { + ContentProviderClient provider = getClient(mDefaultUri); + assertThat(provider).isNotNull(); + Bundle extras = new Bundle(); + extras.putParcelable(EXTRA_URI, mDefaultUri); + Bundle res = provider.call(METHOD_BIND, null, extras); + assertThat(res).isNotNull(); + res.setClassLoader(QCItem.class.getClassLoader()); + Parcelable parcelable = res.getParcelable(EXTRA_ITEM); + assertThat(parcelable).isNotNull(); + assertThat(parcelable instanceof QCItem).isTrue(); + } + + @Test + public void callOnBind_noUri_throwsIllegalArgumentException() throws RemoteException { + ContentProviderClient provider = getClient(mDefaultUri); + assertThat(provider).isNotNull(); + Bundle extras = new Bundle(); + assertThrows(IllegalArgumentException.class, + () -> provider.call(METHOD_BIND, null, extras)); + } + + @Test + public void callOnBind_slowOperation_throwsRuntimeException() { + ContentProviderClient provider = getClient(mSlowUri); + assertThat(provider).isNotNull(); + Bundle extras = new Bundle(); + extras.putParcelable(EXTRA_URI, mSlowUri); + assertThrows(RuntimeException.class, + () -> provider.call(METHOD_BIND, null, extras)); + } + + @Test + public void callOnBind_notAllowed_throwsSecurityException() { + ContentProviderClient provider = getClient(mDeniedUri); + assertThat(provider).isNotNull(); + Bundle extras = new Bundle(); + extras.putParcelable(EXTRA_URI, mDeniedUri); + assertThrows(SecurityException.class, + () -> provider.call(METHOD_BIND, null, extras)); + } + + @Test + public void callOnSubscribed_isSubscribed() throws RemoteException { + ContentProviderClient provider = getClient(mDefaultUri); + assertThat(provider).isNotNull(); + Bundle extras = new Bundle(); + extras.putParcelable(EXTRA_URI, mDefaultUri); + provider.call(METHOD_SUBSCRIBE, null, extras); + + Bundle res = provider.call(METHOD_IS_SUBSCRIBED, null, extras); + assertThat(res).isNotNull(); + boolean isSubscribed = res.getBoolean(IS_SUBSCRIBED_KEY, false); + assertThat(isSubscribed).isTrue(); + } + + @Test + public void callOnUnsubscribed_isUnsubscribed() throws RemoteException { + ContentProviderClient provider = getClient(mDefaultUri); + assertThat(provider).isNotNull(); + Bundle extras = new Bundle(); + extras.putParcelable(EXTRA_URI, mDefaultUri); + provider.call(METHOD_SUBSCRIBE, null, extras); + provider.call(METHOD_UNSUBSCRIBE, null, extras); + + Bundle res = provider.call(METHOD_IS_SUBSCRIBED, null, extras); + assertThat(res).isNotNull(); + boolean isSubscribed = res.getBoolean(IS_SUBSCRIBED_KEY, true); + assertThat(isSubscribed).isFalse(); + } + + @Test + public void callDestroy_isDestroyed() throws RemoteException { + ContentProviderClient provider = getClient(mDefaultUri); + assertThat(provider).isNotNull(); + Bundle extras = new Bundle(); + extras.putParcelable(EXTRA_URI, mDefaultUri); + provider.call(METHOD_SUBSCRIBE, null, extras); + provider.call(METHOD_UNSUBSCRIBE, null, extras); + provider.call(METHOD_DESTROY, null, extras); + + Bundle res = provider.call(METHOD_IS_DESTROYED, null, extras); + assertThat(res).isNotNull(); + boolean isDestroyed = res.getBoolean(IS_DESTROYED_KEY, false); + assertThat(isDestroyed).isTrue(); + } + + private ContentProviderClient getClient(Uri uri) { + return mContext.getContentResolver().acquireContentProviderClient(uri); + } +} diff --git a/car-qc-lib/tests/unit/src/com/android/car/qc/testutils/AllowedTestQCProvider.java b/car-qc-lib/tests/unit/src/com/android/car/qc/testutils/AllowedTestQCProvider.java new file mode 100644 index 0000000..d3cbf87 --- /dev/null +++ b/car-qc-lib/tests/unit/src/com/android/car/qc/testutils/AllowedTestQCProvider.java @@ -0,0 +1,29 @@ +/* + * Copyright (C) 2021 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.qc.testutils; + +import java.util.HashSet; +import java.util.Set; + +public class AllowedTestQCProvider extends TestQCProvider { + @Override + protected Set<String> getAllowlistedPackages() { + Set<String> allowlist = new HashSet<>(); + allowlist.add("com.android.car.qc.tests.unit"); + return allowlist; + } +} diff --git a/car-qc-lib/tests/unit/src/com/android/car/qc/testutils/DeniedTestQCProvider.java b/car-qc-lib/tests/unit/src/com/android/car/qc/testutils/DeniedTestQCProvider.java new file mode 100644 index 0000000..a9c56ce --- /dev/null +++ b/car-qc-lib/tests/unit/src/com/android/car/qc/testutils/DeniedTestQCProvider.java @@ -0,0 +1,27 @@ +/* + * Copyright (C) 2021 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.qc.testutils; + +import java.util.HashSet; +import java.util.Set; + +public class DeniedTestQCProvider extends TestQCProvider { + @Override + protected Set<String> getAllowlistedPackages() { + return new HashSet<>(); + } +} diff --git a/car-qc-lib/tests/unit/src/com/android/car/qc/testutils/TestQCProvider.java b/car-qc-lib/tests/unit/src/com/android/car/qc/testutils/TestQCProvider.java new file mode 100644 index 0000000..8248832 --- /dev/null +++ b/car-qc-lib/tests/unit/src/com/android/car/qc/testutils/TestQCProvider.java @@ -0,0 +1,122 @@ +/* + * Copyright (C) 2021 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.qc.testutils; + +import android.R; +import android.graphics.Bitmap; +import android.graphics.Canvas; +import android.graphics.drawable.BitmapDrawable; +import android.graphics.drawable.Drawable; +import android.graphics.drawable.Icon; +import android.net.Uri; +import android.os.Bundle; + +import androidx.annotation.NonNull; + +import com.android.car.qc.QCItem; +import com.android.car.qc.QCTile; +import com.android.car.qc.provider.BaseQCProvider; + +import java.io.ByteArrayOutputStream; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +public abstract class TestQCProvider extends BaseQCProvider { + + public static final String METHOD_IS_SUBSCRIBED = "METHOD_IS_SUBSCRIBED"; + public static final String IS_SUBSCRIBED_KEY = "IS_SUBSCRIBED"; + public static final String METHOD_IS_DESTROYED = "METHOD_IS_DESTROYED"; + public static final String IS_DESTROYED_KEY = "IS_DESTROYED"; + + public static final String KEY_DEFAULT = "DEFAULT"; + public static final String KEY_SLOW = "SLOW"; + + private final Set<Uri> mSubscribedUris = new HashSet<>(); + private final Set<Uri> mDestroyedUris = new HashSet<>(); + + @Override + public Bundle call(String method, String arg, Bundle extras) { + if (METHOD_IS_SUBSCRIBED.equals(method)) { + Uri uri = getUriWithoutUserId(extras.getParcelable(EXTRA_URI)); + Bundle bundle = new Bundle(); + bundle.putBoolean(IS_SUBSCRIBED_KEY, mSubscribedUris.contains(uri)); + return bundle; + } + if (METHOD_IS_DESTROYED.equals(method)) { + Uri uri = getUriWithoutUserId(extras.getParcelable(EXTRA_URI)); + Bundle bundle = new Bundle(); + bundle.putBoolean(IS_DESTROYED_KEY, mDestroyedUris.contains(uri)); + return bundle; + } + return super.call(method, arg, extras); + } + + @Override + protected QCItem onBind(@NonNull Uri uri) { + List<String> pathSegments = uri.getPathSegments(); + String key = pathSegments.get(0); + + if (KEY_DEFAULT.equals(key)) { + return new QCTile.Builder() + .setIcon(Icon.createWithResource(getContext(), R.drawable.btn_star)) + .build(); + } else if (KEY_SLOW.equals(key)) { + // perform a slow operation that should trigger the strict thread policy + Drawable d = getContext().getDrawable(R.drawable.btn_star); + Bitmap bitmap = drawableToBitmap(d); + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + bitmap.compress(Bitmap.CompressFormat.PNG, 100, baos); + byte[] b = baos.toByteArray(); + Icon icon = Icon.createWithData(b, 0, b.length); + return new QCTile.Builder() + .setIcon(icon) + .build(); + } + return null; + } + + @Override + protected void onSubscribed(@NonNull Uri uri) { + mSubscribedUris.add(uri); + } + + @Override + protected void onUnsubscribed(@NonNull Uri uri) { + mSubscribedUris.remove(uri); + } + + @Override + protected void onDestroy(@NonNull Uri uri) { + mDestroyedUris.add(uri); + } + + private static Bitmap drawableToBitmap(Drawable drawable) { + + if (drawable instanceof BitmapDrawable) { + return ((BitmapDrawable) drawable).getBitmap(); + } + + Bitmap bitmap = Bitmap.createBitmap(drawable.getIntrinsicWidth(), + drawable.getIntrinsicHeight(), Bitmap.Config.ARGB_8888); + Canvas canvas = new Canvas(bitmap); + drawable.setBounds(0, 0, canvas.getWidth(), canvas.getHeight()); + drawable.draw(canvas); + + return bitmap; + } +} diff --git a/car-qc-lib/tests/unit/src/com/android/car/qc/view/QCListViewTest.java b/car-qc-lib/tests/unit/src/com/android/car/qc/view/QCListViewTest.java new file mode 100644 index 0000000..a1065e8 --- /dev/null +++ b/car-qc-lib/tests/unit/src/com/android/car/qc/view/QCListViewTest.java @@ -0,0 +1,105 @@ +/* + * Copyright (C) 2021 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.qc.view; + +import static com.google.common.truth.Truth.assertThat; + +import static org.mockito.Mockito.mock; +import static org.testng.Assert.assertThrows; + +import android.content.Context; + +import androidx.test.core.app.ApplicationProvider; +import androidx.test.ext.junit.runners.AndroidJUnit4; + +import com.android.car.qc.QCList; +import com.android.car.qc.QCRow; +import com.android.dx.mockito.inline.extended.ExtendedMockito; + +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; + +@RunWith(AndroidJUnit4.class) +public class QCListViewTest { + + private final Context mContext = ApplicationProvider.getApplicationContext(); + private QCListView mView; + + @Before + public void setUp() { + mView = new QCListView(mContext); + } + + @Test + public void onChanged_null_noViews() { + mView.onChanged(null); + assertThat(mView.getChildCount()).isEqualTo(0); + } + + @Test + public void onChanged_invalidType_throwsIllegalArgumentException() { + QCRow row = new QCRow.Builder().build(); + assertThrows(IllegalArgumentException.class, + () -> mView.onChanged(row)); + } + + @Test + public void onChanged_createsRows() { + QCList list = new QCList.Builder() + .addRow(new QCRow.Builder().build()) + .addRow(new QCRow.Builder().build()) + .build(); + mView.onChanged(list); + assertThat(mView.getChildCount()).isEqualTo(2); + assertThat(mView.getChildAt(0) instanceof QCRowView).isTrue(); + assertThat(mView.getChildAt(1) instanceof QCRowView).isTrue(); + } + + @Test + public void onChanged_decreasedRowCount_removesExtraRows() { + QCList list = new QCList.Builder() + .addRow(new QCRow.Builder().build()) + .addRow(new QCRow.Builder().build()) + .build(); + mView.onChanged(list); + assertThat(mView.getChildCount()).isEqualTo(2); + list = new QCList.Builder() + .addRow(new QCRow.Builder().build()) + .build(); + mView.onChanged(list); + assertThat(mView.getChildCount()).isEqualTo(1); + } + + @Test + public void setActionListener_setsOnChildView() { + QCList list = new QCList.Builder() + .addRow(new QCRow.Builder().build()) + .addRow(new QCRow.Builder().build()) + .build(); + mView.onChanged(list); + assertThat(mView.getChildCount()).isEqualTo(2); + QCRowView row1 = (QCRowView) mView.getChildAt(0); + QCRowView row2 = (QCRowView) mView.getChildAt(1); + ExtendedMockito.spyOn(row1); + ExtendedMockito.spyOn(row2); + QCView.QCActionListener listener = mock(QCView.QCActionListener.class); + mView.setActionListener(listener); + ExtendedMockito.verify(row1).setActionListener(listener); + ExtendedMockito.verify(row2).setActionListener(listener); + } +} diff --git a/car-qc-lib/tests/unit/src/com/android/car/qc/view/QCRowViewTest.java b/car-qc-lib/tests/unit/src/com/android/car/qc/view/QCRowViewTest.java new file mode 100644 index 0000000..46888f8 --- /dev/null +++ b/car-qc-lib/tests/unit/src/com/android/car/qc/view/QCRowViewTest.java @@ -0,0 +1,211 @@ +/* + * Copyright (C) 2021 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.qc.view; + +import static com.android.car.qc.QCItem.QC_TYPE_ACTION_SWITCH; +import static com.android.car.qc.QCItem.QC_TYPE_ACTION_TOGGLE; + +import static com.google.common.truth.Truth.assertThat; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; + +import android.app.PendingIntent; +import android.content.Context; +import android.content.Intent; +import android.graphics.drawable.Icon; +import android.view.MotionEvent; +import android.view.View; +import android.widget.ImageView; +import android.widget.LinearLayout; +import android.widget.SeekBar; +import android.widget.TextView; + +import androidx.test.annotation.UiThreadTest; +import androidx.test.core.app.ApplicationProvider; +import androidx.test.ext.junit.runners.AndroidJUnit4; + +import com.android.car.qc.QCActionItem; +import com.android.car.qc.QCRow; +import com.android.car.qc.QCSlider; +import com.android.car.qc.R; +import com.android.dx.mockito.inline.extended.ExtendedMockito; + +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; + +@RunWith(AndroidJUnit4.class) +public class QCRowViewTest { + + private final Context mContext = ApplicationProvider.getApplicationContext(); + private QCRowView mView; + + @Before + public void setUp() { + mView = new QCRowView(mContext); + } + + @Test + public void setRow_null_notVisible() { + mView.setRow(null); + assertThat(mView.getVisibility()).isEqualTo(View.GONE); + } + + @Test + public void setRow_notNull_visible() { + QCRow row = new QCRow.Builder().build(); + mView.setRow(row); + assertThat(mView.getVisibility()).isEqualTo(View.VISIBLE); + } + + @Test + public void setRow_setsTitle() { + String title = "TEST_TITLE"; + QCRow row = new QCRow.Builder().setTitle(title).build(); + mView.setRow(row); + TextView titleView = mView.findViewById(R.id.qc_title); + assertThat(titleView.getVisibility()).isEqualTo(View.VISIBLE); + assertThat(titleView.getText().toString()).isEqualTo(title); + } + + @Test + public void setRow_setsSubtitle() { + String subtitle = "TEST_TITLE"; + QCRow row = new QCRow.Builder().setSubtitle(subtitle).build(); + mView.setRow(row); + TextView subtitleView = mView.findViewById(R.id.qc_summary); + assertThat(subtitleView.getVisibility()).isEqualTo(View.VISIBLE); + assertThat(subtitleView.getText().toString()).isEqualTo(subtitle); + } + + @Test + public void setRow_setsIcon() { + Icon icon = Icon.createWithResource(mContext, android.R.drawable.btn_star); + QCRow row = new QCRow.Builder().setIcon(icon).build(); + mView.setRow(row); + ImageView iconView = mView.findViewById(R.id.qc_icon); + assertThat(iconView.getVisibility()).isEqualTo(View.VISIBLE); + assertThat(iconView.getDrawable()).isNotNull(); + } + + @Test + public void setRow_createsStartItems() { + QCRow row = new QCRow.Builder() + .addStartItem(new QCActionItem.Builder(QC_TYPE_ACTION_SWITCH).build()) + .addStartItem(new QCActionItem.Builder(QC_TYPE_ACTION_TOGGLE).build()) + .build(); + mView.setRow(row); + LinearLayout startContainer = mView.findViewById(R.id.qc_row_start_items); + assertThat(startContainer.getChildCount()).isEqualTo(2); + assertThat((View) startContainer.getChildAt(0).findViewById( + R.id.qc_switch_frame)).isNotNull(); + assertThat((View) startContainer.getChildAt(1).findViewById( + R.id.qc_toggle_button)).isNotNull(); + } + + @Test + public void setRow_createsEndItems() { + QCRow row = new QCRow.Builder() + .addEndItem(new QCActionItem.Builder(QC_TYPE_ACTION_SWITCH).build()) + .addEndItem(new QCActionItem.Builder(QC_TYPE_ACTION_TOGGLE).build()) + .build(); + mView.setRow(row); + LinearLayout endContainer = mView.findViewById(R.id.qc_row_end_items); + assertThat(endContainer.getChildCount()).isEqualTo(2); + assertThat((View) endContainer.getChildAt(0).findViewById( + R.id.qc_switch_frame)).isNotNull(); + assertThat((View) endContainer.getChildAt(1).findViewById( + R.id.qc_toggle_button)).isNotNull(); + } + + @Test + public void setRow_noSlider_sliderViewNotVisible() { + QCRow row = new QCRow.Builder().build(); + mView.setRow(row); + LinearLayout sliderContainer = mView.findViewById(R.id.qc_seekbar_wrapper); + assertThat(sliderContainer.getVisibility()).isEqualTo(View.GONE); + } + + @Test + @UiThreadTest + public void setRow_hasSlider_sliderViewVisible() { + QCRow row = new QCRow.Builder() + .addSlider(new QCSlider.Builder().build()) + .build(); + mView.setRow(row); + LinearLayout sliderContainer = mView.findViewById(R.id.qc_seekbar_wrapper); + assertThat(sliderContainer.getVisibility()).isEqualTo(View.VISIBLE); + } + + @Test + public void onRowClick_firesAction() throws PendingIntent.CanceledException { + PendingIntent action = mock(PendingIntent.class); + QCRow row = new QCRow.Builder().setPrimaryAction(action).build(); + mView.setRow(row); + mView.findViewById(R.id.qc_row_content).performClick(); + verify(action).send(any(Context.class), anyInt(), eq(null)); + } + + @Test + public void onSwitchClick_firesAction() throws PendingIntent.CanceledException { + PendingIntent action = mock(PendingIntent.class); + QCRow row = new QCRow.Builder() + .addEndItem( + new QCActionItem.Builder(QC_TYPE_ACTION_SWITCH).setAction(action).build()) + .build(); + mView.setRow(row); + LinearLayout endContainer = mView.findViewById(R.id.qc_row_end_items); + assertThat(endContainer.getChildCount()).isEqualTo(1); + endContainer.getChildAt(0).performClick(); + verify(action).send(any(Context.class), anyInt(), any(Intent.class)); + } + + @Test + @UiThreadTest + public void onToggleClick_firesAction() throws PendingIntent.CanceledException { + PendingIntent action = mock(PendingIntent.class); + QCRow row = new QCRow.Builder() + .addEndItem( + new QCActionItem.Builder(QC_TYPE_ACTION_TOGGLE).setAction(action).build()) + .build(); + mView.setRow(row); + LinearLayout endContainer = mView.findViewById(R.id.qc_row_end_items); + assertThat(endContainer.getChildCount()).isEqualTo(1); + endContainer.getChildAt(0).performClick(); + verify(action).send(any(Context.class), anyInt(), any(Intent.class)); + } + + @Test + @UiThreadTest + public void onSliderChange_firesAction() throws PendingIntent.CanceledException { + PendingIntent action = mock(PendingIntent.class); + QCRow row = new QCRow.Builder() + .addSlider(new QCSlider.Builder().setInputAction(action).build()) + .build(); + mView.setRow(row); + SeekBar seekBar = mView.findViewById(R.id.seekbar); + seekBar.setProgress(50); + MotionEvent motionEvent = ExtendedMockito.mock(MotionEvent.class); + ExtendedMockito.when(motionEvent.getAction()).thenReturn(MotionEvent.ACTION_UP); + seekBar.onTouchEvent(motionEvent); + verify(action).send(any(Context.class), anyInt(), any(Intent.class)); + } +} diff --git a/car-qc-lib/tests/unit/src/com/android/car/qc/view/QCTileViewTest.java b/car-qc-lib/tests/unit/src/com/android/car/qc/view/QCTileViewTest.java new file mode 100644 index 0000000..9104520 --- /dev/null +++ b/car-qc-lib/tests/unit/src/com/android/car/qc/view/QCTileViewTest.java @@ -0,0 +1,114 @@ +/* + * Copyright (C) 2021 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.qc.view; + +import static com.google.common.truth.Truth.assertThat; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.testng.Assert.assertThrows; + +import android.app.PendingIntent; +import android.content.Context; +import android.content.Intent; +import android.graphics.drawable.Drawable; +import android.graphics.drawable.Icon; +import android.graphics.drawable.LayerDrawable; +import android.widget.TextView; + +import androidx.test.annotation.UiThreadTest; +import androidx.test.core.app.ApplicationProvider; +import androidx.test.ext.junit.runners.AndroidJUnit4; + +import com.android.car.qc.QCRow; +import com.android.car.qc.QCTile; +import com.android.car.qc.R; +import com.android.car.ui.uxr.DrawableStateToggleButton; + +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; + +@RunWith(AndroidJUnit4.class) +public class QCTileViewTest { + + private final Context mContext = ApplicationProvider.getApplicationContext(); + private QCTileView mView; + + @Before + public void setUp() { + mView = new QCTileView(mContext); + } + + @Test + public void onChanged_null_noViews() { + mView.onChanged(null); + assertThat(mView.getChildCount()).isEqualTo(0); + } + + @Test + public void onChanged_invalidType_throwsIllegalArgumentException() { + QCRow row = new QCRow.Builder().build(); + assertThrows(IllegalArgumentException.class, + () -> mView.onChanged(row)); + } + + @Test + public void onChanged_setsSubtitleView() { + String subtitle = "TEST_SUBTITLE"; + QCTile tile = new QCTile.Builder().setSubtitle(subtitle).build(); + mView.onChanged(tile); + TextView subtitleView = mView.findViewById(android.R.id.summary); + assertThat(subtitleView.getText().toString()).isEqualTo(subtitle); + } + + @Test + @UiThreadTest + public void onChanged_setsButtonState() { + QCTile tile = new QCTile.Builder().setChecked(true).setEnabled(true).build(); + mView.onChanged(tile); + DrawableStateToggleButton button = mView.findViewById(R.id.qc_tile_toggle_button); + assertThat(button.isEnabled()).isTrue(); + assertThat(button.isChecked()).isTrue(); + } + + @Test + public void onChanged_setsIcon() { + Icon icon = Icon.createWithResource(mContext, android.R.drawable.btn_star); + QCTile tile = new QCTile.Builder().setIcon(icon).build(); + mView.onChanged(tile); + DrawableStateToggleButton button = mView.findViewById(R.id.qc_tile_toggle_button); + Drawable buttonDrawable = button.getButtonDrawable(); + assertThat(buttonDrawable).isNotNull(); + assertThat(buttonDrawable instanceof LayerDrawable).isTrue(); + assertThat(((LayerDrawable) buttonDrawable).getNumberOfLayers()).isEqualTo(2); + } + + @Test + @UiThreadTest + public void onClick_firesAction() throws PendingIntent.CanceledException { + PendingIntent action = mock(PendingIntent.class); + QCTile tile = new QCTile.Builder().setChecked(false).setAction(action).build(); + mView.onChanged(tile); + mView.findViewById(R.id.qc_tile_wrapper).performClick(); + DrawableStateToggleButton button = mView.findViewById(R.id.qc_tile_toggle_button); + assertThat(button.isChecked()).isTrue(); + verify(action).send(any(Context.class), anyInt(), any(Intent.class)); + } +} diff --git a/car-qc-lib/tests/unit/src/com/android/car/qc/view/QCViewTest.java b/car-qc-lib/tests/unit/src/com/android/car/qc/view/QCViewTest.java new file mode 100644 index 0000000..b2090cb --- /dev/null +++ b/car-qc-lib/tests/unit/src/com/android/car/qc/view/QCViewTest.java @@ -0,0 +1,103 @@ +/* + * Copyright (C) 2021 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.qc.view; + +import static com.google.common.truth.Truth.assertThat; + +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.testng.Assert.assertThrows; + +import android.content.Context; + +import androidx.test.core.app.ApplicationProvider; +import androidx.test.ext.junit.runners.AndroidJUnit4; + +import com.android.car.qc.QCList; +import com.android.car.qc.QCRow; +import com.android.car.qc.QCTile; +import com.android.dx.mockito.inline.extended.ExtendedMockito; + +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; + +@RunWith(AndroidJUnit4.class) +public class QCViewTest { + + private final Context mContext = ApplicationProvider.getApplicationContext(); + private QCView mView; + + @Before + public void setUp() { + mView = new QCView(mContext); + } + + @Test + public void onChanged_null_noViews() { + mView.onChanged(null); + assertThat(mView.getChildCount()).isEqualTo(0); + } + + @Test + public void onChanged_invalidType_throwsIllegalArgumentException() { + QCRow row = new QCRow.Builder().build(); + assertThrows(IllegalArgumentException.class, + () -> mView.onChanged(row)); + } + + @Test + public void onChanged_list_createsListView() { + QCList list = new QCList.Builder().build(); + mView.onChanged(list); + assertThat(mView.getChildCount()).isEqualTo(1); + assertThat(mView.getChildAt(0) instanceof QCListView).isTrue(); + } + + @Test + public void onChanged_tile_createsTileView() { + QCTile tile = new QCTile.Builder().build(); + mView.onChanged(tile); + assertThat(mView.getChildCount()).isEqualTo(1); + assertThat(mView.getChildAt(0) instanceof QCTileView).isTrue(); + } + + @Test + public void onChanged_alreadyHasView_callsOnChanged() { + QCTile tile = new QCTile.Builder().build(); + mView.onChanged(tile); + assertThat(mView.getChildCount()).isEqualTo(1); + assertThat(mView.getChildAt(0) instanceof QCTileView).isTrue(); + QCTileView tileView = (QCTileView) mView.getChildAt(0); + ExtendedMockito.spyOn(tileView); + mView.onChanged(tile); + verify(tileView).onChanged(tile); + } + + @Test + public void setActionListener_setsOnChildView() { + QCTile tile = new QCTile.Builder().build(); + mView.onChanged(tile); + assertThat(mView.getChildCount()).isEqualTo(1); + assertThat(mView.getChildAt(0) instanceof QCTileView).isTrue(); + QCTileView tileView = (QCTileView) mView.getChildAt(0); + ExtendedMockito.spyOn(tileView); + QCView.QCActionListener listener = mock(QCView.QCActionListener.class); + mView.setActionListener(listener); + ExtendedMockito.verify(tileView).setActionListener(listener); + } +} |