summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.gitignore22
-rw-r--r--car-qc-lib/Android.bp31
-rw-r--r--car-qc-lib/AndroidManifest.xml19
-rw-r--r--car-qc-lib/OWNERS8
-rw-r--r--car-qc-lib/PREUPLOAD.cfg7
-rw-r--r--car-qc-lib/res/color/qc_toggle_background_color.xml27
-rw-r--r--car-qc-lib/res/color/qc_toggle_icon_fill_color.xml27
-rw-r--r--car-qc-lib/res/drawable/qc_row_action_divider.xml21
-rw-r--r--car-qc-lib/res/drawable/qc_seekbar_wrapper_background.xml27
-rw-r--r--car-qc-lib/res/drawable/qc_toggle_background.xml29
-rw-r--r--car-qc-lib/res/drawable/qc_toggle_rotary_background.xml34
-rw-r--r--car-qc-lib/res/drawable/qc_toggle_unavailable_background.xml31
-rw-r--r--car-qc-lib/res/layout/qc_action_switch.xml30
-rw-r--r--car-qc-lib/res/layout/qc_action_toggle.xml25
-rw-r--r--car-qc-lib/res/layout/qc_row_view.xml142
-rw-r--r--car-qc-lib/res/layout/qc_tile_view.xml41
-rw-r--r--car-qc-lib/res/values/colors.xml22
-rw-r--r--car-qc-lib/res/values/dimens.xml35
-rw-r--r--car-qc-lib/res/values/styles.xml39
-rw-r--r--car-qc-lib/src/com/android/car/qc/QCActionItem.java182
-rw-r--r--car-qc-lib/src/com/android/car/qc/QCItem.java117
-rw-r--r--car-qc-lib/src/com/android/car/qc/QCList.java101
-rw-r--r--car-qc-lib/src/com/android/car/qc/QCRow.java267
-rw-r--r--car-qc-lib/src/com/android/car/qc/QCSlider.java143
-rw-r--r--car-qc-lib/src/com/android/car/qc/QCTile.java194
-rw-r--r--car-qc-lib/src/com/android/car/qc/controller/BaseQCController.java91
-rw-r--r--car-qc-lib/src/com/android/car/qc/controller/LocalQCController.java65
-rw-r--r--car-qc-lib/src/com/android/car/qc/controller/QCItemCallback.java33
-rw-r--r--car-qc-lib/src/com/android/car/qc/controller/RemoteQCController.java273
-rw-r--r--car-qc-lib/src/com/android/car/qc/provider/BaseLocalQCProvider.java97
-rw-r--r--car-qc-lib/src/com/android/car/qc/provider/BaseQCProvider.java231
-rw-r--r--car-qc-lib/src/com/android/car/qc/view/QCListView.java101
-rw-r--r--car-qc-lib/src/com/android/car/qc/view/QCRowView.java440
-rw-r--r--car-qc-lib/src/com/android/car/qc/view/QCTileView.java129
-rw-r--r--car-qc-lib/src/com/android/car/qc/view/QCView.java118
-rw-r--r--car-qc-lib/src/com/android/car/qc/view/QCViewUtils.java95
-rw-r--r--car-qc-lib/tests/unit/Android.bp47
-rw-r--r--car-qc-lib/tests/unit/AndroidManifest.xml41
-rw-r--r--car-qc-lib/tests/unit/src/com/android/car/qc/QCActionItemTest.java101
-rw-r--r--car-qc-lib/tests/unit/src/com/android/car/qc/QCItemTestCase.java44
-rw-r--r--car-qc-lib/tests/unit/src/com/android/car/qc/QCListTest.java62
-rw-r--r--car-qc-lib/tests/unit/src/com/android/car/qc/QCRowTest.java122
-rw-r--r--car-qc-lib/tests/unit/src/com/android/car/qc/QCSliderTest.java68
-rw-r--r--car-qc-lib/tests/unit/src/com/android/car/qc/QCTileTest.java75
-rw-r--r--car-qc-lib/tests/unit/src/com/android/car/qc/controller/BaseQCControllerTestCase.java82
-rw-r--r--car-qc-lib/tests/unit/src/com/android/car/qc/controller/LocalQCControllerTest.java81
-rw-r--r--car-qc-lib/tests/unit/src/com/android/car/qc/controller/RemoteQCControllerTest.java153
-rw-r--r--car-qc-lib/tests/unit/src/com/android/car/qc/provider/BaseLocalQCProviderTest.java102
-rw-r--r--car-qc-lib/tests/unit/src/com/android/car/qc/provider/BaseQCProviderTest.java153
-rw-r--r--car-qc-lib/tests/unit/src/com/android/car/qc/testutils/AllowedTestQCProvider.java29
-rw-r--r--car-qc-lib/tests/unit/src/com/android/car/qc/testutils/DeniedTestQCProvider.java27
-rw-r--r--car-qc-lib/tests/unit/src/com/android/car/qc/testutils/TestQCProvider.java122
-rw-r--r--car-qc-lib/tests/unit/src/com/android/car/qc/view/QCListViewTest.java105
-rw-r--r--car-qc-lib/tests/unit/src/com/android/car/qc/view/QCRowViewTest.java211
-rw-r--r--car-qc-lib/tests/unit/src/com/android/car/qc/view/QCTileViewTest.java114
-rw-r--r--car-qc-lib/tests/unit/src/com/android/car/qc/view/QCViewTest.java103
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);
+ }
+}