fix: 引入Settings的Module

This commit is contained in:
2024-12-10 14:57:24 +08:00
parent ad8fc8731d
commit df105485bd
6934 changed files with 896168 additions and 2 deletions

31
car-qc-lib/Android.bp Normal file
View File

@@ -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",
srcs: ["src/**/*.java"],
optimize: {
enabled: false,
},
static_libs: [
"androidx.annotation_annotation",
"car-ui-lib-no-overlayable",
"car-resource-common",
],
}

View File

@@ -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>

8
car-qc-lib/OWNERS Normal file
View File

@@ -0,0 +1,8 @@
# People who can approve changes for submission.
# Primary
alexstetson@google.com
# Secondary (only if people in Primary are unreachable)
babakbo@google.com
igorr@google.com

7
car-qc-lib/PREUPLOAD.cfg Normal file
View File

@@ -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

50
car-qc-lib/build.gradle Normal file
View File

@@ -0,0 +1,50 @@
/**
* Include this gradle file if you are building against this as a standalone gradle library project,
* as opposed to building it as part of the git-tree. This is typically the file you want to include
* if you create a new project in Android Studio. Besides, SetupDesign library is dependent with
* SetupCompat library, you should also include setupcompat library in project.
*
* For example, you can include the following in your settings.gradle file:
* include ':setupdesign'
* project(':setupdesign').projectDir = new File(PATH_TO_THIS_DIRECTORY)
* include ':setupcompat'
* project(':setupcompat').projectDir = new File(PATH_TO_THIS_DIRECTORY)
*
* And then you can include the :setupdesign project as one of your dependencies
* dependencies {
* implementation project(path: ':setupdesign')
* }
*/
plugins {
alias(libs.plugins.android.library)
alias(libs.plugins.kotlin.android)
}
android {
compileSdk 34
namespace = "com.android.car.qc"
defaultConfig {
minSdkVersion 31
targetSdkVersion 34
}
buildTypes {
release {
minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard.flags'
}
}
sourceSets {
main {
manifest.srcFile 'AndroidManifest.xml'
java.srcDirs = ['src']
res.srcDirs = ['res']
}
}
}
dependencies {
implementation libs.androidx.annotation.annotation
}

View File

@@ -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>

View File

@@ -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>

View File

@@ -0,0 +1,21 @@
<!--
~ Copyright (C) 2022 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_row_horizontal_margin"/>
</shape>

View File

@@ -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>

View File

@@ -0,0 +1,28 @@
<?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_background_size"
android:height="@dimen/qc_toggle_background_size"
android:start="@dimen/qc_toggle_background_padding"
android:top="@dimen/qc_toggle_background_padding"
android:drawable="@drawable/qc_toggle_button_background">
</item>
<item android:width="@dimen/qc_toggle_rotary_highlight_size"
android:height="@dimen/qc_toggle_rotary_highlight_size"
android:drawable="@drawable/qc_toggle_rotary_background"/>
</layer-list>

View File

@@ -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"
xmlns:app="http://schemas.android.com/apk/res-auto">
<item app:state_toggle_unavailable="true">
<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>
<shape android:shape="rectangle">
<solid android:color="@color/qc_toggle_background_color" />
<corners android:radius="@dimen/qc_toggle_background_radius" />
</shape>
</item>
</selector>

View File

@@ -0,0 +1,28 @@
<?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:width="@dimen/qc_toggle_rotary_shadow_size"
android:height="@dimen/qc_toggle_rotary_shadow_size"
android:start="@dimen/qc_toggle_rotary_shadow_padding"
android:top="@dimen/qc_toggle_rotary_shadow_padding"
android:drawable="@drawable/qc_toggle_rotary_shadow">
</item>
<item android:width="@dimen/qc_toggle_rotary_highlight_size"
android:height="@dimen/qc_toggle_rotary_highlight_size"
android:drawable="@drawable/qc_toggle_rotary_highlight"/>
</layer-list>

View File

@@ -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_stroke_color"/>
<corners android:radius="@dimen/qc_toggle_rotary_highlight_radius" />
</shape>
</item>
<item android:state_focused="true">
<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"/>
<corners android:radius="@dimen/qc_toggle_rotary_highlight_radius" />
</shape>
</item>
</selector>

View File

@@ -0,0 +1,25 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
~ Copyright (C) 2022 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="false">
<shape android:shape="rectangle">
<stroke android:width="@dimen/qc_toggle_rotary_shadow_width"
android:color="@color/qc_toggle_rotary_shadow_color"/>
<corners android:radius="@dimen/qc_toggle_rotary_shadow_radius" />
</shape>
</item>
</selector>

View File

@@ -0,0 +1,33 @@
<?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_background_size"
android:height="@dimen/qc_toggle_background_size"
android:start="@dimen/qc_toggle_background_padding"
android:top="@dimen/qc_toggle_background_padding">
<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_rotary_highlight_size"
android:height="@dimen/qc_toggle_rotary_highlight_size"
android:drawable="@drawable/qc_toggle_rotary_background"/>
</layer-list>

View File

@@ -0,0 +1,21 @@
<?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.DrawableStateSwitch
xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@android:id/switch_widget"
android:layout_width="wrap_content"
android:layout_height="wrap_content" />

View File

@@ -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"/>

View File

@@ -0,0 +1,143 @@
<?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:layout_marginVertical="@dimen/qc_row_margin_vertical"
android:clipToPadding="false"
android:minHeight="@dimen/qc_row_min_height"
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="0dp"
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"
app:layout_constraintHeight_default="wrap"
app:layout_constraintHeight_min="@dimen/qc_row_min_height">
<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: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"
style="@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"
style="@style/TextAppearance.QC.Subtitle"
app:layout_constraintStart_toEndOf="@+id/barrier1"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toBottomOf="@+id/qc_title"
app:layout_constraintBottom_toTopOf="@+id/barrier2"/>
<androidx.constraintlayout.widget.Barrier
android:id="@+id/barrier2"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:barrierDirection="top"
app:constraint_referenced_ids="qc_seekbar_wrapper"/>
<androidx.preference.UnPressableLinearLayout
android:id="@+id/qc_seekbar_wrapper"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:paddingTop="@dimen/qc_seekbar_padding_top"
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">
<com.android.car.qc.view.QCSeekBarView
android:id="@+id/qc_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>

View File

@@ -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"
style="@style/TextAppearance.QC.Subtitle"/>
</com.android.car.ui.uxr.DrawableStateLinearLayout>

View File

@@ -0,0 +1,19 @@
<!--
~ Copyright (C) 2022 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>
<attr name="state_toggle_unavailable"/>
</resources>

View File

@@ -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.
-->
<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">#37FFFFFF</color>
<color name="qc_toggle_rotary_shadow_color">#C7000000</color>
<!-- The SeekBar thumb color. -->
<color name="qc_seekbar_thumb">#FFFFFF</color>
<!-- The SeekBar thumb color when disabled. Use for the dark theme. -->
<color name="qc_seekbar_thumb_disabled_on_dark">#757575</color>
<!-- The Switch thumb color. -->
<color name="qc_switch_thumb_color">#FFFFFF</color>
<!-- The Switch thumb color when disabled. Use for the dark theme. -->
<color name="qc_switch_thumb_color_disabled_on_dark">#757575</color>
</resources>

View File

@@ -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.
-->
<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_margin_vertical">10dp</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">80dp</dimen>
<dimen name="qc_toggle_background_size">72dp</dimen>
<dimen name="qc_toggle_margin">12dp</dimen>
<dimen name="qc_row_horizontal_margin">16dp</dimen>
<dimen name="qc_toggle_background_radius">16dp</dimen>
<dimen name="qc_toggle_background_padding">4dp</dimen>
<dimen name="qc_toggle_foreground_icon_inset">18dp</dimen>
<dimen name="qc_toggle_unavailable_outline_width">2dp</dimen>
<dimen name="qc_toggle_rotary_highlight_size">80dp</dimen>
<dimen name="qc_toggle_rotary_highlight_radius">20dp</dimen>
<dimen name="qc_toggle_rotary_shadow_size">64dp</dimen>
<dimen name="qc_toggle_rotary_shadow_width">4dp</dimen>
<dimen name="qc_toggle_rotary_shadow_radius">16dp</dimen>
<dimen name="qc_toggle_rotary_shadow_padding">8dp</dimen>
<dimen name="qc_seekbar_padding_top">16dp</dimen>
</resources>

View File

@@ -0,0 +1,38 @@
<!--
~ 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_on_surface</item>
</style>
<style name="TextAppearance.QC.Title" parent="android:TextAppearance.DeviceDefault.Large">
<item name="android:textColor">@color/car_on_surface</item>
</style>
<style name="TextAppearance.QC.Subtitle" parent="android:TextAppearance.DeviceDefault.Small">
<item name="android:textColor">@color/car_on_surface_variant</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>

View File

@@ -0,0 +1,266 @@
/*
* 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.graphics.drawable.Icon;
import android.os.Parcel;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.StringRes;
/**
* 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 mIsAvailable;
private final boolean mIsClickable;
private Icon mIcon;
private PendingIntent mAction;
private PendingIntent mDisabledClickAction;
private String mContentDescription;
public QCActionItem(@NonNull @QCItemType String type, boolean isChecked, boolean isEnabled,
boolean isAvailable, boolean isClickable, boolean isClickableWhileDisabled,
@Nullable Icon icon, @Nullable String contentDescription,
@Nullable PendingIntent action, @Nullable PendingIntent disabledClickAction) {
super(type, isEnabled, isClickableWhileDisabled);
mIsChecked = isChecked;
mIsAvailable = isAvailable;
mIsClickable = isClickable;
mIcon = icon;
mContentDescription = contentDescription;
mAction = action;
mDisabledClickAction = disabledClickAction;
}
public QCActionItem(@NonNull Parcel in) {
super(in);
mIsChecked = in.readBoolean();
mIsAvailable = in.readBoolean();
mIsClickable = in.readBoolean();
boolean hasIcon = in.readBoolean();
if (hasIcon) {
mIcon = Icon.CREATOR.createFromParcel(in);
}
boolean hasContentDescription = in.readBoolean();
if (hasContentDescription) {
mContentDescription = in.readString();
}
boolean hasAction = in.readBoolean();
if (hasAction) {
mAction = PendingIntent.CREATOR.createFromParcel(in);
}
boolean hasDisabledClickAction = in.readBoolean();
if (hasDisabledClickAction) {
mDisabledClickAction = PendingIntent.CREATOR.createFromParcel(in);
}
}
@Override
public void writeToParcel(Parcel dest, int flags) {
super.writeToParcel(dest, flags);
dest.writeBoolean(mIsChecked);
dest.writeBoolean(mIsAvailable);
dest.writeBoolean(mIsClickable);
boolean includeIcon = getType().equals(QC_TYPE_ACTION_TOGGLE) && mIcon != null;
dest.writeBoolean(includeIcon);
if (includeIcon) {
mIcon.writeToParcel(dest, flags);
}
boolean hasContentDescription = mContentDescription != null;
dest.writeBoolean(hasContentDescription);
if (hasContentDescription) {
dest.writeString(mContentDescription);
}
boolean hasAction = mAction != null;
dest.writeBoolean(hasAction);
if (hasAction) {
mAction.writeToParcel(dest, flags);
}
boolean hasDisabledClickAction = mDisabledClickAction != null;
dest.writeBoolean(hasDisabledClickAction);
if (hasDisabledClickAction) {
mDisabledClickAction.writeToParcel(dest, flags);
}
}
@Override
public PendingIntent getPrimaryAction() {
return mAction;
}
@Override
public PendingIntent getDisabledClickAction() {
return mDisabledClickAction;
}
public boolean isChecked() {
return mIsChecked;
}
public boolean isAvailable() {
return mIsAvailable;
}
public boolean isClickable() {
return mIsClickable;
}
@Nullable
public Icon getIcon() {
return mIcon;
}
@Nullable
public String getContentDescription() {
return mContentDescription;
}
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 boolean mIsClickable = true;
private boolean mIsClickableWhileDisabled = false;
private Icon mIcon;
private PendingIntent mAction;
private PendingIntent mDisabledClickAction;
private String mContentDescription;
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 whether the action is clickable. This differs from available in that the style will
* remain as if it's enabled/available but click actions will not be processed.
*/
public Builder setClickable(boolean clickable) {
mIsClickable = clickable;
return this;
}
/**
* Sets whether or not an action item should be clickable while disabled.
*/
public Builder setClickableWhileDisabled(boolean clickable) {
mIsClickableWhileDisabled = clickable;
return this;
}
/**
* Sets the icon for {@link QC_TYPE_ACTION_TOGGLE} actions
*/
public Builder setIcon(@Nullable Icon icon) {
mIcon = icon;
return this;
}
/**
* Sets the content description
*/
public Builder setContentDescription(@Nullable String contentDescription) {
mContentDescription = contentDescription;
return this;
}
/**
* Sets the string resource to use for content description
*/
public Builder setContentDescription(@NonNull Context context,
@StringRes int contentDescriptionResId) {
mContentDescription = context.getString(contentDescriptionResId);
return this;
}
/**
* Sets the PendingIntent to be sent when the action item is clicked.
*/
public Builder setAction(@Nullable PendingIntent action) {
mAction = action;
return this;
}
/**
* Sets the PendingIntent to be sent when the action item is clicked while disabled.
*/
public Builder setDisabledClickAction(@Nullable PendingIntent action) {
mDisabledClickAction = action;
return this;
}
/**
* Builds the final {@link QCActionItem}.
*/
public QCActionItem build() {
return new QCActionItem(mType, mIsChecked, mIsEnabled, mIsAvailable, mIsClickable,
mIsClickableWhileDisabled, mIcon, mContentDescription, mAction,
mDisabledClickAction);
}
private boolean isValidType(String type) {
return type.equals(QC_TYPE_ACTION_SWITCH) || type.equals(QC_TYPE_ACTION_TOGGLE);
}
}
}

View File

@@ -0,0 +1,154 @@
/*
* 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 final boolean mIsEnabled;
private final boolean mIsClickableWhileDisabled;
private ActionHandler mActionHandler;
private ActionHandler mDisabledClickActionHandler;
public QCItem(@NonNull @QCItemType String type) {
this(type, /* isEnabled= */true, /* isClickableWhileDisabled= */ false);
}
public QCItem(@NonNull @QCItemType String type, boolean isEnabled,
boolean isClickableWhileDisabled) {
mType = type;
mIsEnabled = isEnabled;
mIsClickableWhileDisabled = isClickableWhileDisabled;
}
public QCItem(@NonNull Parcel in) {
mType = in.readString();
mIsEnabled = in.readBoolean();
mIsClickableWhileDisabled = in.readBoolean();
}
@NonNull
@QCItemType
public String getType() {
return mType;
}
public boolean isEnabled() {
return mIsEnabled;
}
public boolean isClickableWhileDisabled() {
return mIsClickableWhileDisabled;
}
@Override
public int describeContents() {
return 0;
}
@Override
public void writeToParcel(Parcel dest, int flags) {
dest.writeString(mType);
dest.writeBoolean(mIsEnabled);
dest.writeBoolean(mIsClickableWhileDisabled);
}
public void setActionHandler(@Nullable ActionHandler handler) {
mActionHandler = handler;
}
public void setDisabledClickActionHandler(@Nullable ActionHandler handler) {
mDisabledClickActionHandler = handler;
}
@Nullable
public ActionHandler getActionHandler() {
return mActionHandler;
}
@Nullable
public ActionHandler getDisabledClickActionHandler() {
return mDisabledClickActionHandler;
}
/**
* Returns the PendingIntent that is sent when the item is clicked.
*/
@Nullable
public abstract PendingIntent getPrimaryAction();
/**
* Returns the PendingIntent that is sent when the item is clicked while disabled.
*/
@Nullable
public abstract PendingIntent getDisabledClickAction();
/**
* 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;
}
}
}

View File

@@ -0,0 +1,106 @@
/*
* 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;
}
@Override
public PendingIntent getDisabledClickAction() {
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);
}
}
}

View File

@@ -0,0 +1,315 @@
/*
* 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;
private PendingIntent mDisabledClickAction;
public QCRow(@Nullable String title, @Nullable String subtitle, boolean isEnabled,
boolean isClickableWhileDisabled, @Nullable PendingIntent primaryAction,
@Nullable PendingIntent disabledClickAction, @Nullable Icon startIcon,
boolean isIconTintable, @Nullable QCSlider slider,
@NonNull List<QCActionItem> startItems, @NonNull List<QCActionItem> endItems) {
super(QC_TYPE_ROW, isEnabled, isClickableWhileDisabled);
mTitle = title;
mSubtitle = subtitle;
mPrimaryAction = primaryAction;
mDisabledClickAction = disabledClickAction;
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;
}
boolean hasDisabledClickAction = in.readBoolean();
if (hasDisabledClickAction) {
mDisabledClickAction = PendingIntent.CREATOR.createFromParcel(in);
} else {
mDisabledClickAction = 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);
}
boolean hasPrimaryAction = mPrimaryAction != null;
dest.writeBoolean(hasPrimaryAction);
if (hasPrimaryAction) {
mPrimaryAction.writeToParcel(dest, flags);
}
boolean hasDisabledClickAction = mDisabledClickAction != null;
dest.writeBoolean(hasDisabledClickAction);
if (hasDisabledClickAction) {
mDisabledClickAction.writeToParcel(dest, flags);
}
}
@Override
public PendingIntent getPrimaryAction() {
return mPrimaryAction;
}
@Override
public PendingIntent getDisabledClickAction() {
return mDisabledClickAction;
}
@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 boolean mIsEnabled = true;
private boolean mIsClickableWhileDisabled = false;
private QCSlider mSlider;
private PendingIntent mPrimaryAction;
private PendingIntent mDisabledClickAction;
/**
* 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 whether or not the row is enabled. Note that this only affects the main row area,
* not the action items contained within the row.
*/
public Builder setEnabled(boolean enabled) {
mIsEnabled = enabled;
return this;
}
/**
* Sets whether or not the row should be clickable while disabled.
*/
public Builder setClickableWhileDisabled(boolean clickable) {
mIsClickableWhileDisabled = clickable;
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;
}
/**
* Sets the PendingIntent to be sent when the action item is clicked while disabled.
*/
public Builder setDisabledClickAction(@Nullable PendingIntent action) {
mDisabledClickAction = 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, mIsEnabled, mIsClickableWhileDisabled,
mPrimaryAction, mDisabledClickAction, mStartIcon, mIsStartIconTintable,
mSlider, mStartItems, mEndItems);
}
}
}

View File

@@ -0,0 +1,189 @@
/*
* 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;
private PendingIntent mDisabledClickAction;
public QCSlider(int min, int max, int value, boolean enabled, boolean clickableWhileDisabled,
@Nullable PendingIntent inputAction, @Nullable PendingIntent disabledClickAction) {
super(QC_TYPE_SLIDER, enabled, clickableWhileDisabled);
mMin = min;
mMax = max;
mValue = value;
mInputAction = inputAction;
mDisabledClickAction = disabledClickAction;
}
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);
}
boolean hasDisabledClickAction = in.readBoolean();
if (hasDisabledClickAction) {
mDisabledClickAction = 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);
}
boolean hasDisabledClickAction = mDisabledClickAction != null;
dest.writeBoolean(hasDisabledClickAction);
if (hasDisabledClickAction) {
mDisabledClickAction.writeToParcel(dest, flags);
}
}
@Override
public PendingIntent getPrimaryAction() {
return mInputAction;
}
@Override
public PendingIntent getDisabledClickAction() {
return mDisabledClickAction;
}
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 boolean mIsEnabled = true;
private boolean mIsClickableWhileDisabled = false;
private PendingIntent mInputAction;
private PendingIntent mDisabledClickAction;
/**
* 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;
}
/**
* Sets whether or not the slider is enabled.
*/
public Builder setEnabled(boolean enabled) {
mIsEnabled = enabled;
return this;
}
/**
* Sets whether or not a slider should be clickable while disabled.
*/
public Builder setClickableWhileDisabled(boolean clickable) {
mIsClickableWhileDisabled = clickable;
return this;
}
/**
* Set the PendingIntent to be sent when the slider value is changed.
*/
public Builder setInputAction(@Nullable PendingIntent inputAction) {
mInputAction = inputAction;
return this;
}
/**
* Sets the PendingIntent to be sent when the action item is clicked while disabled.
*/
public Builder setDisabledClickAction(@Nullable PendingIntent action) {
mDisabledClickAction = action;
return this;
}
/**
* Builds the final {@link QCSlider}.
*/
public QCSlider build() {
return new QCSlider(mMin, mMax, mValue, mIsEnabled, mIsClickableWhileDisabled,
mInputAction, mDisabledClickAction);
}
}
}

View File

@@ -0,0 +1,222 @@
/*
* 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 mIsAvailable;
private final String mSubtitle;
private Icon mIcon;
private PendingIntent mAction;
private PendingIntent mDisabledClickAction;
public QCTile(boolean isChecked, boolean isEnabled, boolean isAvailable,
boolean isClickableWhileDisabled, @Nullable String subtitle, @Nullable Icon icon,
@Nullable PendingIntent action, @Nullable PendingIntent disabledClickAction) {
super(QC_TYPE_TILE, isEnabled, isClickableWhileDisabled);
mIsChecked = isChecked;
mIsAvailable = isAvailable;
mSubtitle = subtitle;
mIcon = icon;
mAction = action;
mDisabledClickAction = disabledClickAction;
}
public QCTile(@NonNull Parcel in) {
super(in);
mIsChecked = 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);
}
boolean hasDisabledClickAction = in.readBoolean();
if (hasDisabledClickAction) {
mDisabledClickAction = PendingIntent.CREATOR.createFromParcel(in);
}
}
@Override
public void writeToParcel(Parcel dest, int flags) {
super.writeToParcel(dest, flags);
dest.writeBoolean(mIsChecked);
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);
}
boolean hasDisabledClickAction = mDisabledClickAction != null;
dest.writeBoolean(hasDisabledClickAction);
if (hasDisabledClickAction) {
mDisabledClickAction.writeToParcel(dest, flags);
}
}
@Override
public PendingIntent getPrimaryAction() {
return mAction;
}
@Override
public PendingIntent getDisabledClickAction() {
return mDisabledClickAction;
}
public boolean isChecked() {
return mIsChecked;
}
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 boolean mIsClickableWhileDisabled = false;
private String mSubtitle;
private Icon mIcon;
private PendingIntent mAction;
private PendingIntent mDisabledClickAction;
/**
* 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 whether or not a tile should be clickable while disabled.
*/
public Builder setClickableWhileDisabled(boolean clickable) {
mIsClickableWhileDisabled = clickable;
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;
}
/**
* Sets the PendingIntent to be sent when the action item is clicked while disabled.
*/
public Builder setDisabledClickAction(@Nullable PendingIntent action) {
mDisabledClickAction = action;
return this;
}
/**
* Builds the final {@link QCTile}.
*/
public QCTile build() {
return new QCTile(mIsChecked, mIsEnabled, mIsAvailable, mIsClickableWhileDisabled,
mSubtitle, mIcon, mAction, mDisabledClickAction);
}
}
}

View File

@@ -0,0 +1,96 @@
/*
* 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();
}
/**
* Perform a single retrieval from the provider (without subscribing to live updates).
*/
public abstract void bind();
/**
* Subclasses must override this method to handle a listening update.
*/
protected abstract void updateListening();
}

View File

@@ -0,0 +1,70 @@
/*
* 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
public void bind() {
onQCItemUpdated(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();
}
}

View File

@@ -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);
}

View File

@@ -0,0 +1,278 @@
/*
* 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
public void bind() {
mBackgroundExecutor.execute(this::updateQCItem);
}
@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 = getQCItem();
mContext.getMainExecutor().execute(() -> onQCItemUpdated(item));
} catch (Exception e) {
Log.d(TAG, "Error fetching QCItem", e);
}
}
private QCItem getQCItem() {
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 = getQCItem();
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;
}
}
}
}

View File

@@ -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();
}
}
}

View File

@@ -0,0 +1,235 @@
/*
* 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()
// TODO(268275789): Revert back to penaltyDeath and ensure it works in
// presubmit
.penaltyLog()
.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()));
}
}
}

View File

@@ -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);
}
}
}

View File

@@ -0,0 +1,538 @@
/*
* 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.CarUiUtils;
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;
@Nullable
private QCSlider mQCSlider;
private QCSeekBarView 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 || (!mSeekBar.isEnabled()
&& !mSeekBar.isClickableWhileDisabled())) {
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) {
if (mQCSlider != null) {
if (mQCSlider.isEnabled()) {
setInDirectManipulationMode(v, mSeekBar, !mInDirectManipulationMode);
} else {
fireAction(mQCSlider, new Intent());
}
}
}
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.qc_seekbar);
}
void setActionListener(QCActionListener listener) {
mActionListener = listener;
}
void setRow(QCRow row) {
if (row == null) {
setVisibility(GONE);
return;
}
setVisibility(VISIBLE);
CarUiUtils.makeAllViewsEnabled(mContentView, row.isEnabled());
if (!row.isEnabled()) {
if (row.isClickableWhileDisabled() && (row.getDisabledClickAction() != null
|| row.getDisabledClickActionHandler() != null)) {
mContentView.setOnClickListener(v -> {
fireAction(row, /* intent= */ null);
});
}
} else 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);
mQCSlider = null;
}
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) {
Switch switchView = actionView == null ? null : actionView.findViewById(
android.R.id.switch_widget);
if (switchView == null) {
actionView = createActionView(root, actionView, R.layout.qc_action_switch);
switchView = actionView.requireViewById(android.R.id.switch_widget);
}
CarUiUtils.makeAllViewsEnabled(switchView, action.isEnabled());
boolean shouldEnableView =
(action.isEnabled() || action.isClickableWhileDisabled()) && action.isAvailable()
&& action.isClickable();
switchView.setOnCheckedChangeListener(null);
switchView.setEnabled(shouldEnableView);
switchView.setChecked(action.isChecked());
switchView.setContentDescription(action.getContentDescription());
switchView.setOnTouchListener((v, event) -> {
if (!action.isEnabled()) {
if (event.getActionMasked() == MotionEvent.ACTION_UP) {
fireAction(action, new Intent());
}
return true;
}
return false;
});
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 tmpToggleButton =
actionView == null ? null : actionView.findViewById(R.id.qc_toggle_button);
if (tmpToggleButton == null) {
actionView = createActionView(root, actionView, R.layout.qc_action_toggle);
tmpToggleButton = actionView.requireViewById(R.id.qc_toggle_button);
}
DrawableStateToggleButton toggleButton = tmpToggleButton; // must be effectively final
boolean shouldEnableView =
(action.isEnabled() || action.isClickableWhileDisabled()) && action.isAvailable()
&& action.isClickable();
toggleButton.setText(null);
toggleButton.setTextOn(null);
toggleButton.setTextOff(null);
toggleButton.setOnCheckedChangeListener(null);
Drawable icon = QCViewUtils.getToggleIcon(mContext, action.getIcon(), action.isAvailable());
toggleButton.setContentDescription(action.getContentDescription());
toggleButton.setButtonDrawable(icon);
toggleButton.setChecked(action.isChecked());
toggleButton.setEnabled(shouldEnableView);
setToggleButtonDrawableState(toggleButton, action.isEnabled(), action.isAvailable());
toggleButton.setOnTouchListener((v, event) -> {
if (!action.isEnabled()) {
if (event.getActionMasked() == MotionEvent.ACTION_UP) {
fireAction(action, new Intent());
}
return true;
}
return false;
});
toggleButton.setOnCheckedChangeListener(
(buttonView, isChecked) -> {
Intent intent = new Intent();
intent.putExtra(QC_ACTION_TOGGLE_STATE, isChecked);
fireAction(action, intent);
});
}
private void setToggleButtonDrawableState(DrawableStateToggleButton view,
boolean enabled, boolean available) {
int[] statesToAdd = null;
int[] statesToRemove = null;
if (enabled) {
if (!available) {
statesToAdd =
new int[]{android.R.attr.state_enabled, R.attr.state_toggle_unavailable};
} else {
statesToAdd = new int[]{android.R.attr.state_enabled};
statesToRemove = new int[]{R.attr.state_toggle_unavailable};
}
} else {
if (available) {
statesToRemove =
new int[]{android.R.attr.state_enabled, R.attr.state_toggle_unavailable};
} else {
statesToAdd = new int[]{R.attr.state_toggle_unavailable};
statesToRemove = new int[]{android.R.attr.state_enabled};
}
}
CarUiUtils.applyDrawableStatesToAllViews(view, statesToAdd, statesToRemove);
}
@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, /* attachToRoot= */ false);
root.addView(actionView);
return actionView;
}
private void initSlider(QCSlider slider) {
mQCSlider = slider;
CarUiUtils.makeAllViewsEnabled(mSeekBar, slider.isEnabled());
mSeekBar.setOnSeekBarChangeListener(null);
mSeekBar.setMin(slider.getMin());
mSeekBar.setMax(slider.getMax());
mSeekBar.setProgress(slider.getValue());
mSeekBar.setEnabled(slider.isEnabled());
mSeekBar.setClickableWhileDisabled(slider.isClickableWhileDisabled());
mSeekBar.setDisabledClickListener(seekBar -> fireAction(slider, new Intent()));
if (!slider.isEnabled() && mInDirectManipulationMode) {
setInDirectManipulationMode(mSeekBarContainer, mSeekBar, false);
}
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.isEnabled()) {
if (item.getDisabledClickAction() != null) {
try {
item.getDisabledClickAction().send(getContext(), 0, intent);
if (mActionListener != null) {
mActionListener.onQCAction(item, item.getDisabledClickAction());
}
} catch (PendingIntent.CanceledException e) {
Log.d(TAG, "Error sending intent", e);
}
} else if (item.getDisabledClickActionHandler() != null) {
item.getDisabledClickActionHandler().onAction(item, getContext(), intent);
if (mActionListener != null) {
mActionListener.onQCAction(item, item.getDisabledClickActionHandler());
}
}
return;
}
if (item.getPrimaryAction() != null) {
try {
item.getPrimaryAction().send(getContext(), 0, intent);
if (mActionListener != null) {
mActionListener.onQCAction(item, item.getPrimaryAction());
}
} 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, item.getActionHandler());
}
}
}
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);
}
}
}

View File

@@ -0,0 +1,78 @@
/*
* Copyright (C) 2022 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.MotionEvent;
import android.widget.SeekBar;
import androidx.annotation.Nullable;
import com.android.car.ui.uxr.DrawableStateSeekBar;
import java.util.function.Consumer;
/**
* A {@link SeekBar} specifically for Quick Controls that allows for a disabled click action
* to execute on {@link MotionEvent.ACTION_UP}.
*/
public class QCSeekBarView extends DrawableStateSeekBar {
private boolean mClickableWhileDisabled;
private Consumer<SeekBar> mDisabledClickListener;
public QCSeekBarView(Context context) {
super(context);
}
public QCSeekBarView(Context context, AttributeSet attrs) {
super(context, attrs);
}
public QCSeekBarView(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
}
public QCSeekBarView(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) {
super(context, attrs, defStyleAttr, defStyleRes);
}
@Override
public boolean onTouchEvent(MotionEvent event) {
// AbsSeekBar will ignore all touch events if not enabled. If this SeekBar should be
// clickable while disabled, the touch event will be handled here.
if (!isEnabled() && mClickableWhileDisabled) {
if (event.getAction() == MotionEvent.ACTION_UP && mDisabledClickListener != null) {
mDisabledClickListener.accept(this);
}
return true;
}
return super.onTouchEvent(event);
}
public void setClickableWhileDisabled(boolean clickable) {
mClickableWhileDisabled = clickable;
}
public void setDisabledClickListener(@Nullable Consumer<SeekBar> disabledClickListener) {
mDisabledClickListener = disabledClickListener;
}
public boolean isClickableWhileDisabled() {
return mClickableWhileDisabled;
}
}

View File

@@ -0,0 +1,151 @@
/*
* 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.utils.CarUiUtils;
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());
CarUiUtils.makeAllViewsEnabled(mToggleButton, qcTile.isEnabled());
mToggleButton.setOnCheckedChangeListener(null);
mToggleButton.setChecked(qcTile.isChecked());
mToggleButton.setEnabled(qcTile.isEnabled() || qcTile.isClickableWhileDisabled());
mTileWrapper.setEnabled(
(qcTile.isEnabled() || qcTile.isClickableWhileDisabled()) && qcTile.isAvailable());
mTileWrapper.setOnClickListener(v -> {
if (!qcTile.isEnabled()) {
if (qcTile.getDisabledClickAction() != null) {
try {
qcTile.getDisabledClickAction().send(getContext(), 0, new Intent());
if (mActionListener != null) {
mActionListener.onQCAction(qcTile, qcTile.getDisabledClickAction());
}
} catch (PendingIntent.CanceledException e) {
Log.d(TAG, "Error sending intent", e);
}
} else if (qcTile.getDisabledClickActionHandler() != null) {
qcTile.getDisabledClickActionHandler().onAction(qcTile, getContext(),
new Intent());
if (mActionListener != null) {
mActionListener.onQCAction(qcTile, qcTile.getDisabledClickActionHandler());
}
}
return;
}
mToggleButton.toggle();
});
Drawable icon = QCViewUtils.getToggleIcon(mContext, 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, qcTile.getPrimaryAction());
}
} 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, qcTile.getActionHandler());
}
}
});
}
}

View File

@@ -0,0 +1,120 @@
/*
* 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.
* @param action the action that was executed - is generally either a
* {@link android.app.PendingIntent} or {@link QCItem.ActionHandler}
*/
void onQCAction(@NonNull QCItem item, @NonNull Object action);
}
}

View File

@@ -0,0 +1,74 @@
/*
* 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.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 {
/**
* Create a return a Quick Control toggle icon - used for tiles and action toggles.
*/
public static Drawable getToggleIcon(@NonNull Context context, @Nullable Icon icon,
boolean available) {
Drawable defaultToggleBackground = context.getDrawable(R.drawable.qc_toggle_background);
Drawable unavailableToggleBackground = context.getDrawable(
R.drawable.qc_toggle_unavailable_background);
int toggleForegroundIconInset = context.getResources()
.getDimensionPixelSize(R.dimen.qc_toggle_foreground_icon_inset);
Drawable background = available
? defaultToggleBackground.getConstantState().newDrawable().mutate()
: unavailableToggleBackground.getConstantState().newDrawable().mutate();
if (icon == null) {
return background;
}
Drawable iconDrawable = icon.loadDrawable(context);
if (iconDrawable == null) {
return background;
}
if (!available) {
int unavailableToggleIconTint = context.getColor(R.color.qc_toggle_unavailable_color);
iconDrawable.setTint(unavailableToggleIconTint);
} else {
ColorStateList defaultToggleIconTint = context.getColorStateList(
R.color.qc_toggle_icon_fill_color);
iconDrawable.setTintList(defaultToggleIconTint);
}
Drawable[] layers = {background, iconDrawable};
LayerDrawable drawable = new LayerDrawable(layers);
drawable.setLayerInsetRelative(/* index= */ 1, toggleForegroundIconInset,
toggleForegroundIconInset, toggleForegroundIconInset,
toggleForegroundIconInset);
return drawable;
}
}

View File

@@ -0,0 +1,50 @@
//
// 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",
"testng",
],
jni_libs: [
"libdexmakerjvmtiagent",
"libstaticjvmtiagent",
],
}

View File

@@ -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>

View File

@@ -0,0 +1,115 @@
/*
* 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> {
private static final String TEST_CONTENT_DESCRIPTION = "test_content_description";
@Test
public void onCreate_invalidType_throwsException() {
assertThrows(IllegalArgumentException.class,
() -> createAction("INVALID_TYPE", /* action= */ null,
/* disabledAction= */ null, /* icon= */ null, /* contentDescription=*/
null));
}
@Test
public void onCreateSwitch_hasCorrectType() {
QCActionItem action = createAction(QC_TYPE_ACTION_SWITCH, /* action= */ null,
/* disabledAction= */ null, /* icon= */null, /* contentDescription=*/ null);
assertThat(action.getType()).isEqualTo(QC_TYPE_ACTION_SWITCH);
}
@Test
public void onCreateToggle_hasCorrectType() {
QCActionItem action = createAction(QC_TYPE_ACTION_TOGGLE, /* action= */ null,
/* disabledAction= */ null, /* icon= */ null, /* contentDescription=*/ null);
assertThat(action.getType()).isEqualTo(QC_TYPE_ACTION_TOGGLE);
}
@Test
public void onBundle_nullActions_noCrash() {
QCActionItem action = createAction(QC_TYPE_ACTION_TOGGLE, /* action= */ null,
/* disabledAction= */ null, mDefaultIcon, /* contentDescription=*/ null);
writeAndLoadFromBundle(action);
// Test passes if this doesn't crash
}
@Test
public void onBundle_nullIcon_noCrash() {
QCActionItem action = createAction(QC_TYPE_ACTION_TOGGLE, mDefaultAction,
mDefaultDisabledAction, /* icon= */ null, /* contentDescription=*/ null);
writeAndLoadFromBundle(action);
// Test passes if this doesn't crash
}
@Test
public void onBundle_switch_accurateData() {
QCActionItem action = createAction(QC_TYPE_ACTION_SWITCH, mDefaultAction,
mDefaultDisabledAction, /* icon= */ null, TEST_CONTENT_DESCRIPTION);
QCActionItem newAction = writeAndLoadFromBundle(action);
assertThat(newAction.getType()).isEqualTo(QC_TYPE_ACTION_SWITCH);
assertThat(newAction.isChecked()).isTrue();
assertThat(newAction.isEnabled()).isTrue();
assertThat(newAction.isClickableWhileDisabled()).isFalse();
assertThat(newAction.getPrimaryAction()).isNotNull();
assertThat(newAction.getIcon()).isNull();
assertThat(newAction.getContentDescription()).isEqualTo(TEST_CONTENT_DESCRIPTION);
}
@Test
public void onBundle_toggle_accurateDate() {
QCActionItem action = createAction(QC_TYPE_ACTION_TOGGLE, mDefaultAction,
mDefaultDisabledAction, mDefaultIcon, TEST_CONTENT_DESCRIPTION);
QCActionItem newAction = writeAndLoadFromBundle(action);
assertThat(newAction.getType()).isEqualTo(QC_TYPE_ACTION_TOGGLE);
assertThat(newAction.isChecked()).isTrue();
assertThat(newAction.isEnabled()).isTrue();
assertThat(newAction.isClickableWhileDisabled()).isFalse();
assertThat(newAction.getPrimaryAction()).isNotNull();
assertThat(newAction.getIcon()).isNotNull();
assertThat(newAction.getContentDescription()).isEqualTo(TEST_CONTENT_DESCRIPTION);
}
private QCActionItem createAction(String type, PendingIntent action,
PendingIntent disabledAction, Icon icon, String contentDescription) {
return new QCActionItem.Builder(type)
.setChecked(true)
.setEnabled(true)
.setAction(action)
.setDisabledClickAction(disabledAction)
.setIcon(icon)
.setContentDescription(contentDescription)
.build();
}
}

View File

@@ -0,0 +1,46 @@
/*
* 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 PendingIntent mDefaultDisabledAction = PendingIntent.getActivity(mContext,
/* requestCode= */ 1, 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);
}
}

View File

@@ -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();
}
}

View File

@@ -0,0 +1,124 @@
/*
* 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, /* disabledAction= */ null, /* icon= */ null);
assertThat(row.getType()).isEqualTo(QC_TYPE_ROW);
}
@Test
public void onBundle_nullActions_noCrash() {
QCRow row = createRow(/* action= */ null, /* disabledAction= */ null, mDefaultIcon);
writeAndLoadFromBundle(row);
// Test passes if this doesn't crash
}
@Test
public void onBundle_nullIcon_noCrash() {
QCRow row = createRow(mDefaultAction, mDefaultDisabledAction, /* icon= */ null);
writeAndLoadFromBundle(row);
// Test passes if this doesn't crash
}
@Test
public void createFromParcel_accurateData() {
QCRow row = createRow(mDefaultAction, mDefaultDisabledAction, 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, /* disabledAction= */ 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, /* disabledAction= */ 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, /* disabledAction= */ null, /* icon= */ null,
Collections.emptyList(), Collections.emptyList(),
Collections.singletonList(slider));
QCRow newRow = writeAndLoadFromBundle(row);
assertThat(newRow.getSlider()).isNotNull();
}
private QCRow createRow(PendingIntent action, PendingIntent disabledAction, Icon icon) {
return createRow(action, disabledAction, icon, Collections.emptyList(),
Collections.emptyList(), Collections.emptyList());
}
private QCRow createRow(PendingIntent action, PendingIntent disabledAction, 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)
.setDisabledClickAction(disabledAction);
for (QCActionItem item : startItems) {
builder.addStartItem(item);
}
for (QCActionItem item : endItems) {
builder.addEndItem(item);
}
for (QCSlider slider : sliders) {
builder.addSlider(slider);
}
return builder.build();
}
}

View File

@@ -0,0 +1,70 @@
/*
* 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, /* disabledAction= */ null);
assertThat(slider.getType()).isEqualTo(QC_TYPE_SLIDER);
}
@Test
public void onBundle_nullActions_noCrash() {
QCSlider slider = createSlider(/* action= */ null, /* disabledAction= */ null);
writeAndLoadFromBundle(slider);
// Test passes if this doesn't crash
}
@Test
public void createFromParcel_accurateData() {
QCSlider slider = createSlider(mDefaultAction, mDefaultDisabledAction);
QCSlider newSlider = writeAndLoadFromBundle(slider);
assertThat(newSlider.getType()).isEqualTo(QC_TYPE_SLIDER);
assertThat(newSlider.getPrimaryAction()).isNotNull();
assertThat(newSlider.getDisabledClickAction()).isNotNull();
assertThat(newSlider.getMin()).isEqualTo(MIN);
assertThat(newSlider.getMax()).isEqualTo(MAX);
assertThat(newSlider.getValue()).isEqualTo(VALUE);
}
private QCSlider createSlider(PendingIntent action, PendingIntent disabledAction) {
return new QCSlider.Builder()
.setMin(MIN)
.setMax(MAX)
.setValue(VALUE)
.setInputAction(action)
.setDisabledClickAction(disabledAction)
.build();
}
}

View File

@@ -0,0 +1,77 @@
/*
* 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, /* disabledAction= */ null, /* icon= */ null);
assertThat(tile.getType()).isEqualTo(QC_TYPE_TILE);
}
@Test
public void onBundle_nullAction_noCrash() {
QCTile tile = createTile(/* action= */ null, /* disabledAction= */ null, mDefaultIcon);
writeAndLoadFromBundle(tile);
// Test passes if this doesn't crash
}
@Test
public void onBundle_nullIcon_noCrash() {
QCTile tile = createTile(mDefaultAction, mDefaultDisabledAction, /* icon= */ null);
writeAndLoadFromBundle(tile);
// Test passes if this doesn't crash
}
@Test
public void createFromParcel_accurateData() {
QCTile tile = createTile(mDefaultAction, mDefaultDisabledAction, 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.getDisabledClickAction()).isNotNull();
assertThat(newTile.getIcon()).isNotNull();
}
private QCTile createTile(PendingIntent action, PendingIntent disabledAction, Icon icon) {
return new QCTile.Builder()
.setSubtitle(TEST_SUBTITLE)
.setChecked(true)
.setEnabled(true)
.setAction(action)
.setDisabledClickAction(disabledAction)
.setIcon(icon)
.build();
}
}

View File

@@ -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();
}
}

View File

@@ -0,0 +1,92 @@
/*
* 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 onBind_updatesQCItem() {
Observer<QCItem> observer = mock(Observer.class);
LocalQCController spiedController = spy(getController());
spiedController.addObserver(observer);
Mockito.reset(mProvider);
spiedController.bind();
verify(mProvider).getQCItem();
verify(spiedController).onQCItemUpdated(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();
}
}

View File

@@ -0,0 +1,163 @@
/*
* 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 onBind_updatesQCItem() {
Observer<QCItem> observer = mock(Observer.class);
RemoteQCController spiedController = spy(getController());
spiedController.addObserver(observer);
spiedController.bind();
InstrumentationRegistry.getInstrumentation().waitForIdleSync();
verify(spiedController).onQCItemUpdated(notNull());
}
@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();
}
}

View File

@@ -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;
}
}
}

View File

@@ -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);
}
}

View File

@@ -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;
}
}

View File

@@ -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<>();
}
}

View File

@@ -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;
}
}

View File

@@ -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);
}
}

View File

@@ -0,0 +1,254 @@
/*
* 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.content.res.ColorStateList;
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.Switch;
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
@UiThreadTest
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(
android.R.id.switch_widget)).isNotNull();
assertThat((View) startContainer.getChildAt(1).findViewById(
R.id.qc_toggle_button)).isNotNull();
}
@Test
@UiThreadTest
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(
android.R.id.switch_widget)).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.qc_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));
}
@Test
@UiThreadTest
public void setRow_switchViewThumbTintList() {
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);
Switch switchView = (Switch) endContainer.getChildAt(0);
assertThat(switchView.getThumbTintList()).isNotNull();
ColorStateList switchColorStateList = switchView.getThumbTintList();
int[] enabledState = {android.R.attr.state_enabled};
int[] disabledState = {-android.R.attr.state_enabled};
assertThat(switchColorStateList.getColorForState(enabledState, 0)).isNotEqualTo(
switchColorStateList.getColorForState(disabledState, 0));
}
@Test
@UiThreadTest
public void setRow_sliderViewThumbTintList() {
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.qc_seekbar);
assertThat(seekBar.getThumbTintList()).isNotNull();
ColorStateList seekBarColorStateList = seekBar.getThumbTintList();
int[] enabledState = {android.R.attr.state_enabled};
int[] disabledState = {-android.R.attr.state_enabled};
assertThat(seekBarColorStateList.getColorForState(enabledState, 0)).isNotEqualTo(
seekBarColorStateList.getColorForState(disabledState, 0));
}
}

View File

@@ -0,0 +1,97 @@
/*
* Copyright (C) 2022 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.when;
import android.content.Context;
import android.view.MotionEvent;
import android.widget.SeekBar;
import androidx.test.core.app.ApplicationProvider;
import androidx.test.ext.junit.runners.AndroidJUnit4;
import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.mockito.Mock;
import org.mockito.MockitoAnnotations;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.function.Consumer;
@RunWith(AndroidJUnit4.class)
public class QCSeekBarViewTest {
private final Context mContext = ApplicationProvider.getApplicationContext();
private final QCSeekBarView mView = new QCSeekBarView(mContext);
@Mock
private MotionEvent mMotionEvent;
@Before
public void setUp() {
MockitoAnnotations.initMocks(this);
when(mMotionEvent.getAction()).thenReturn(MotionEvent.ACTION_UP);
}
@Test
public void enabled_standardTouchEvent() {
assertThat(mView.onTouchEvent(mMotionEvent)).isTrue();
}
@Test
public void disabled_standardTouchEvent() {
mView.setEnabled(false);
assertThat(mView.onTouchEvent(mMotionEvent)).isFalse();
}
@Test
public void clickableWhileDisabled_customTouchEvent() {
mView.setEnabled(false);
mView.setClickableWhileDisabled(true);
assertThat(mView.onTouchEvent(mMotionEvent)).isTrue();
}
@Test
public void clickableWhileDisabled_actionDown_doesNotTriggerDisabledClickListener() {
AtomicBoolean called = new AtomicBoolean(false);
Consumer<SeekBar> disabledClickListener = seekBar -> called.set(true);
mView.setEnabled(false);
mView.setClickableWhileDisabled(true);
mView.setDisabledClickListener(disabledClickListener);
when(mMotionEvent.getAction()).thenReturn(MotionEvent.ACTION_DOWN);
assertThat(mView.onTouchEvent(mMotionEvent)).isTrue();
assertThat(called.get()).isFalse();
}
@Test
public void clickableWhileDisabled_actionUp_triggersDisabledClickListener() {
AtomicBoolean called = new AtomicBoolean(false);
Consumer<SeekBar> disabledClickListener = seekBar -> called.set(true);
mView.setEnabled(false);
mView.setClickableWhileDisabled(true);
mView.setDisabledClickListener(disabledClickListener);
assertThat(mView.onTouchEvent(mMotionEvent)).isTrue();
assertThat(called.get()).isTrue();
}
}

View File

@@ -0,0 +1,116 @@
/*
* 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
@UiThreadTest
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
@UiThreadTest
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));
}
}

View File

@@ -0,0 +1,107 @@
/*
* 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.annotation.UiThreadTest;
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
@UiThreadTest
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
@UiThreadTest
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
@UiThreadTest
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);
}
}