diff options
51 files changed, 3237 insertions, 26 deletions
diff --git a/RotaryPlayground/Android.bp b/RotaryPlayground/Android.bp new file mode 100644 index 0000000..b8304ad --- /dev/null +++ b/RotaryPlayground/Android.bp @@ -0,0 +1,37 @@ +// +// Copyright (C) 2020 The Android Open Source Project +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +android_app { + name: "RotaryPlayground", + + srcs: ["src/**/*.java"], + resource_dirs: ["res"], + + sdk_version: "system_current", + + static_libs: [ + "androidx-constraintlayout_constraintlayout", + "car-apps-common", + "car-ui-lib", + ], + + owner: "google", + certificate: "shared", + + optimize: { + enabled: false, + } +} diff --git a/RotaryPlayground/AndroidManifest.xml b/RotaryPlayground/AndroidManifest.xml new file mode 100644 index 0000000..4aac33f --- /dev/null +++ b/RotaryPlayground/AndroidManifest.xml @@ -0,0 +1,33 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + ~ Copyright (C) 2020 The Android Open Source Project + ~ + ~ Licensed under the Apache License, Version 2.0 (the "License"); + ~ you may not use this file except in compliance with the License. + ~ You may obtain a copy of the License at + ~ + ~ http://www.apache.org/licenses/LICENSE-2.0 + ~ + ~ Unless required by applicable law or agreed to in writing, software + ~ distributed under the License is distributed on an "AS IS" BASIS, + ~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + ~ See the License for the specific language governing permissions and + ~ limitations under the License. + --> +<manifest xmlns:android="http://schemas.android.com/apk/res/android" + package="com.android.car.rotaryplayground"> + <application + android:icon="@drawable/ic_launcher" + android:label="@string/app_name" + android:theme="@style/Theme.App"> + <activity + android:name=".RotaryActivity" + android:label="@string/app_name" + android:allowEmbedded="true"> + <intent-filter> + <action android:name="android.intent.action.MAIN" /> + <category android:name="android.intent.category.LAUNCHER" /> + </intent-filter> + </activity> + </application> +</manifest>
\ No newline at end of file diff --git a/RotaryPlayground/readme.md b/RotaryPlayground/readme.md new file mode 100644 index 0000000..ee74185 --- /dev/null +++ b/RotaryPlayground/readme.md @@ -0,0 +1,16 @@ +# Rotary Playground: Test app for rotary controller + +## Building +``` +make RotaryPlayground +``` + +## Installing +``` +adb install out/target/product/[hardware]/system/app/RotaryPlayground/RotaryPlayground.apk +``` + +## Once installed, launch Rotary Playground in the Launcher, or with this adb command: +``` +adb shell am start -n com.android.car.rotaryplayground/com.android.car.rotaryplayground.RotaryActivity +```
\ No newline at end of file diff --git a/RotaryPlayground/res/drawable/ic_launcher.png b/RotaryPlayground/res/drawable/ic_launcher.png Binary files differnew file mode 100644 index 0000000..5d5dc1a --- /dev/null +++ b/RotaryPlayground/res/drawable/ic_launcher.png diff --git a/RotaryPlayground/res/drawable/ic_launcher_background.xml b/RotaryPlayground/res/drawable/ic_launcher_background.xml new file mode 100644 index 0000000..1cb2f28 --- /dev/null +++ b/RotaryPlayground/res/drawable/ic_launcher_background.xml @@ -0,0 +1,186 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + ~ Copyright (C) 2020 The Android Open Source Project + ~ + ~ Licensed under the Apache License, Version 2.0 (the "License"); + ~ you may not use this file except in compliance with the License. + ~ You may obtain a copy of the License at + ~ + ~ http://www.apache.org/licenses/LICENSE-2.0 + ~ + ~ Unless required by applicable law or agreed to in writing, software + ~ distributed under the License is distributed on an "AS IS" BASIS, + ~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + ~ See the License for the specific language governing permissions and + ~ limitations under the License + --> + +<vector xmlns:android="http://schemas.android.com/apk/res/android" + android:width="108dp" + android:height="108dp" + android:viewportHeight="108" + android:viewportWidth="108"> + <path + android:fillColor="#26A69A" + android:pathData="M0,0h108v108h-108z" /> + <path + android:fillColor="#00000000" + android:pathData="M9,0L9,108" + android:strokeColor="#33FFFFFF" + android:strokeWidth="0.8" /> + <path + android:fillColor="#00000000" + android:pathData="M19,0L19,108" + android:strokeColor="#33FFFFFF" + android:strokeWidth="0.8" /> + <path + android:fillColor="#00000000" + android:pathData="M29,0L29,108" + android:strokeColor="#33FFFFFF" + android:strokeWidth="0.8" /> + <path + android:fillColor="#00000000" + android:pathData="M39,0L39,108" + android:strokeColor="#33FFFFFF" + android:strokeWidth="0.8" /> + <path + android:fillColor="#00000000" + android:pathData="M49,0L49,108" + android:strokeColor="#33FFFFFF" + android:strokeWidth="0.8" /> + <path + android:fillColor="#00000000" + android:pathData="M59,0L59,108" + android:strokeColor="#33FFFFFF" + android:strokeWidth="0.8" /> + <path + android:fillColor="#00000000" + android:pathData="M69,0L69,108" + android:strokeColor="#33FFFFFF" + android:strokeWidth="0.8" /> + <path + android:fillColor="#00000000" + android:pathData="M79,0L79,108" + android:strokeColor="#33FFFFFF" + android:strokeWidth="0.8" /> + <path + android:fillColor="#00000000" + android:pathData="M89,0L89,108" + android:strokeColor="#33FFFFFF" + android:strokeWidth="0.8" /> + <path + android:fillColor="#00000000" + android:pathData="M99,0L99,108" + android:strokeColor="#33FFFFFF" + android:strokeWidth="0.8" /> + <path + android:fillColor="#00000000" + android:pathData="M0,9L108,9" + android:strokeColor="#33FFFFFF" + android:strokeWidth="0.8" /> + <path + android:fillColor="#00000000" + android:pathData="M0,19L108,19" + android:strokeColor="#33FFFFFF" + android:strokeWidth="0.8" /> + <path + android:fillColor="#00000000" + android:pathData="M0,29L108,29" + android:strokeColor="#33FFFFFF" + android:strokeWidth="0.8" /> + <path + android:fillColor="#00000000" + android:pathData="M0,39L108,39" + android:strokeColor="#33FFFFFF" + android:strokeWidth="0.8" /> + <path + android:fillColor="#00000000" + android:pathData="M0,49L108,49" + android:strokeColor="#33FFFFFF" + android:strokeWidth="0.8" /> + <path + android:fillColor="#00000000" + android:pathData="M0,59L108,59" + android:strokeColor="#33FFFFFF" + android:strokeWidth="0.8" /> + <path + android:fillColor="#00000000" + android:pathData="M0,69L108,69" + android:strokeColor="#33FFFFFF" + android:strokeWidth="0.8" /> + <path + android:fillColor="#00000000" + android:pathData="M0,79L108,79" + android:strokeColor="#33FFFFFF" + android:strokeWidth="0.8" /> + <path + android:fillColor="#00000000" + android:pathData="M0,89L108,89" + android:strokeColor="#33FFFFFF" + android:strokeWidth="0.8" /> + <path + android:fillColor="#00000000" + android:pathData="M0,99L108,99" + android:strokeColor="#33FFFFFF" + android:strokeWidth="0.8" /> + <path + android:fillColor="#00000000" + android:pathData="M19,29L89,29" + android:strokeColor="#33FFFFFF" + android:strokeWidth="0.8" /> + <path + android:fillColor="#00000000" + android:pathData="M19,39L89,39" + android:strokeColor="#33FFFFFF" + android:strokeWidth="0.8" /> + <path + android:fillColor="#00000000" + android:pathData="M19,49L89,49" + android:strokeColor="#33FFFFFF" + android:strokeWidth="0.8" /> + <path + android:fillColor="#00000000" + android:pathData="M19,59L89,59" + android:strokeColor="#33FFFFFF" + android:strokeWidth="0.8" /> + <path + android:fillColor="#00000000" + android:pathData="M19,69L89,69" + android:strokeColor="#33FFFFFF" + android:strokeWidth="0.8" /> + <path + android:fillColor="#00000000" + android:pathData="M19,79L89,79" + android:strokeColor="#33FFFFFF" + android:strokeWidth="0.8" /> + <path + android:fillColor="#00000000" + android:pathData="M29,19L29,89" + android:strokeColor="#33FFFFFF" + android:strokeWidth="0.8" /> + <path + android:fillColor="#00000000" + android:pathData="M39,19L39,89" + android:strokeColor="#33FFFFFF" + android:strokeWidth="0.8" /> + <path + android:fillColor="#00000000" + android:pathData="M49,19L49,89" + android:strokeColor="#33FFFFFF" + android:strokeWidth="0.8" /> + <path + android:fillColor="#00000000" + android:pathData="M59,19L59,89" + android:strokeColor="#33FFFFFF" + android:strokeWidth="0.8" /> + <path + android:fillColor="#00000000" + android:pathData="M69,19L69,89" + android:strokeColor="#33FFFFFF" + android:strokeWidth="0.8" /> + <path + android:fillColor="#00000000" + android:pathData="M79,19L79,89" + android:strokeColor="#33FFFFFF" + android:strokeWidth="0.8" /> +</vector> diff --git a/RotaryPlayground/res/drawable/ic_launcher_foreground.xml b/RotaryPlayground/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 0000000..ca1f579 --- /dev/null +++ b/RotaryPlayground/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,50 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + ~ Copyright (C) 2020 The Android Open Source Project + ~ + ~ Licensed under the Apache License, Version 2.0 (the "License"); + ~ you may not use this file except in compliance with the License. + ~ You may obtain a copy of the License at + ~ + ~ http://www.apache.org/licenses/LICENSE-2.0 + ~ + ~ Unless required by applicable law or agreed to in writing, software + ~ distributed under the License is distributed on an "AS IS" BASIS, + ~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + ~ See the License for the specific language governing permissions and + ~ limitations under the License + --> +<vector xmlns:android="http://schemas.android.com/apk/res/android" + xmlns:aapt="http://schemas.android.com/aapt" + android:width="108dp" + android:height="108dp" + android:viewportHeight="108" + android:viewportWidth="108"> + <path + android:fillType="evenOdd" + android:pathData="M32,64C32,64 38.39,52.99 44.13,50.95C51.37,48.37 70.14,49.57 70.14,49.57L108.26,87.69L108,109.01L75.97,107.97L32,64Z" + android:strokeColor="#00000000" + android:strokeWidth="1"> + <aapt:attr name="android:fillColor"> + <gradient + android:endX="78.5885" + android:endY="90.9159" + android:startX="48.7653" + android:startY="61.0927" + android:type="linear"> + <item + android:color="#44000000" + android:offset="0.0" /> + <item + android:color="#00000000" + android:offset="1.0" /> + </gradient> + </aapt:attr> + </path> + <path + android:fillColor="#FFFFFF" + android:fillType="nonZero" + android:pathData="M66.94,46.02L66.94,46.02C72.44,50.07 76,56.61 76,64L32,64C32,56.61 35.56,50.11 40.98,46.06L36.18,41.19C35.45,40.45 35.45,39.3 36.18,38.56C36.91,37.81 38.05,37.81 38.78,38.56L44.25,44.05C47.18,42.57 50.48,41.71 54,41.71C57.48,41.71 60.78,42.57 63.68,44.05L69.11,38.56C69.84,37.81 70.98,37.81 71.71,38.56C72.44,39.3 72.44,40.45 71.71,41.19L66.94,46.02ZM62.94,56.92C64.08,56.92 65,56.01 65,54.88C65,53.76 64.08,52.85 62.94,52.85C61.8,52.85 60.88,53.76 60.88,54.88C60.88,56.01 61.8,56.92 62.94,56.92ZM45.06,56.92C46.2,56.92 47.13,56.01 47.13,54.88C47.13,53.76 46.2,52.85 45.06,52.85C43.92,52.85 43,53.76 43,54.88C43,56.01 43.92,56.92 45.06,56.92Z" + android:strokeColor="#00000000" + android:strokeWidth="1" /> +</vector> diff --git a/RotaryPlayground/res/drawable/seek_bar_background.xml b/RotaryPlayground/res/drawable/seek_bar_background.xml new file mode 100644 index 0000000..8d66a3b --- /dev/null +++ b/RotaryPlayground/res/drawable/seek_bar_background.xml @@ -0,0 +1,20 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + ~ Copyright (C) 2020 The Android Open Source Project + ~ + ~ Licensed under the Apache License, Version 2.0 (the "License"); + ~ you may not use this file except in compliance with the License. + ~ You may obtain a copy of the License at + ~ + ~ http://www.apache.org/licenses/LICENSE-2.0 + ~ + ~ Unless required by applicable law or agreed to in writing, software + ~ distributed under the License is distributed on an "AS IS" BASIS, + ~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + ~ See the License for the specific language governing permissions and + ~ limitations under the License. + --> + +<ripple xmlns:android="http://schemas.android.com/apk/res/android" + android:color="@color/car_ui_rotary_focus_color" + android:radius="16dp" /> diff --git a/RotaryPlayground/res/layout/rotary_activity.xml b/RotaryPlayground/res/layout/rotary_activity.xml new file mode 100644 index 0000000..2a3b9e7 --- /dev/null +++ b/RotaryPlayground/res/layout/rotary_activity.xml @@ -0,0 +1,43 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + ~ Copyright (C) 2020 The Android Open Source Project + ~ + ~ Licensed under the Apache License, Version 2.0 (the "License"); + ~ you may not use this file except in compliance with the License. + ~ You may obtain a copy of the License at + ~ + ~ http://www.apache.org/licenses/LICENSE-2.0 + ~ + ~ Unless required by applicable law or agreed to in writing, software + ~ distributed under the License is distributed on an "AS IS" BASIS, + ~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + ~ See the License for the specific language governing permissions and + ~ limitations under the License. + --> + +<LinearLayout + xmlns:android="http://schemas.android.com/apk/res/android" + android:layout_width="match_parent" + android:layout_height="match_parent"> + + <!-- When not in touch mode, if we clear focus in current window, Android will re-focus the + first focusable view in the window automatically. Adding a FocusParkingView to the window + can fix this issue, because it can take focus, and it is transparent and its default focus + highlight is disabled, so it's invisible to the user no matter whether it's focused or not. + --> + <com.android.car.ui.FocusParkingView + android:layout_width="wrap_content" + android:layout_height="wrap_content"/> + + <FrameLayout + android:id="@+id/rotary_menu" + android:layout_width="@dimen/menu_width" + android:layout_height="match_parent" /> + + <FrameLayout + android:id="@+id/rotary_content" + android:layout_height="match_parent" + android:layout_weight="1" + android:layout_width="0dp" /> + +</LinearLayout>
\ No newline at end of file diff --git a/RotaryPlayground/res/layout/rotary_cards.xml b/RotaryPlayground/res/layout/rotary_cards.xml new file mode 100644 index 0000000..e3d9e87 --- /dev/null +++ b/RotaryPlayground/res/layout/rotary_cards.xml @@ -0,0 +1,310 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + ~ Copyright (C) 2020 The Android Open Source Project + ~ + ~ Licensed under the Apache License, Version 2.0 (the "License"); + ~ you may not use this file except in compliance with the License. + ~ You may obtain a copy of the License at + ~ + ~ http://www.apache.org/licenses/LICENSE-2.0 + ~ + ~ Unless required by applicable law or agreed to in writing, software + ~ distributed under the License is distributed on an "AS IS" BASIS, + ~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + ~ See the License for the specific language governing permissions and + ~ limitations under the License. + --> +<LinearLayout + xmlns:android="http://schemas.android.com/apk/res/android" + xmlns:app="http://schemas.android.com/apk/res-auto" + android:layout_width="match_parent" + android:layout_height="match_parent" + android:orientation="vertical"> + + <com.android.car.ui.FocusArea + android:layout_margin="16dp" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:orientation="vertical"> + <EditText + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:singleLine="true"> + </EditText> + </com.android.car.ui.FocusArea> + + <HorizontalScrollView + android:layout_width="match_parent" + android:layout_height="wrap_content"> + <LinearLayout + android:layout_width="match_parent" + android:layout_height="wrap_content"> + + <!-- A FocusArea with some buttons. + Upon nudging into this area, the default focus should land on the first + button in this container. Rotating the rotary controller clockwise will move the + focus from top to bottom. Counterclockwise to move the focus from bottom to up. + Nudge any direction to leave this focus area. The disabled button is skipped. --> + <com.android.car.ui.FocusArea + android:id="@+id/card_normal" + android:background="@color/card_background_color" + android:layout_margin="16dp" + android:layout_width="@dimen/card_width" + android:layout_height="match_parent" + android:padding="@dimen/card_padding" + android:orientation="vertical"> + <TextView + android:layout_height="@dimen/description_height" + android:layout_width="match_parent" + android:text="@string/card_normal_text" /> + <Button + android:layout_width="match_parent" + android:layout_height="50dp" + android:onClick="onRotaryButtonClick" + android:tag="test_button" + android:text="Button" /> + + <!-- This button is disabled and is not focusable by RotaryService. + The RotaryService can see disabled views but it can't focus them. Android + doesn't allow disabled views to be focused. + The focus will skip this button when rotating the rotary countroller. --> + <Button + android:background="@color/button_disabled_background_color" + android:layout_width="match_parent" + android:layout_height="50dp" + android:onClick="onRotaryButtonClick" + android:enabled="false" + android:tag="test_button" + android:text="Disabled" /> + <Button + android:layout_width="match_parent" + android:layout_height="50dp" + android:onClick="onRotaryButtonClick" + android:tag="test_button" + android:text="Button" /> + <Button + android:layout_width="match_parent" + android:layout_height="50dp" + android:onClick="onRotaryButtonClick" + android:tag="test_button" + android:text="Button" /> + </com.android.car.ui.FocusArea> + + <!-- A FocusArea where all elements are disabled. Elements that are disabled + (android:enabled="false") or not focusable (android:focusable="false") are + discoverable by the RotaryService, but the RotaryService will not focus them. + When no elements is focusable, nudging left and right from the adjacent cards + will cause focus to appear to skip this card --> + <com.android.car.ui.FocusArea + android:id="@+id/card_disabled" + android:background="@color/card_disabled_background_color" + android:layout_margin="16dp" + android:layout_width="@dimen/card_width" + android:layout_height="match_parent" + android:padding="@dimen/card_padding" + android:orientation="vertical"> + <TextView + android:layout_height="@dimen/description_height" + android:layout_width="match_parent" + android:text="@string/card_disabled_text" /> + <Button + android:layout_width="match_parent" + android:layout_height="50dp" + android:onClick="onRotaryButtonClick" + android:enabled="false" + android:tag="test_button" + android:text="Disabled" /> + <Button + android:layout_width="match_parent" + android:layout_height="50dp" + android:onClick="onRotaryButtonClick" + android:enabled="false" + android:tag="test_button" + android:text="Disabled" /> + <Button + android:layout_width="match_parent" + android:layout_height="50dp" + android:onClick="onRotaryButtonClick" + android:enabled="false" + android:tag="test_button" + android:text="Disabled" /> + <Button + android:layout_width="match_parent" + android:layout_height="50dp" + android:onClick="onRotaryButtonClick" + android:enabled="false" + android:tag="test_button" + android:text="Disabled" /> + </com.android.car.ui.FocusArea> + + <!-- A FocusArea that contains a button with a default focus attribute. + Upon nudging into this area, the focus will land on the button with the default + focus attribute. Rotating the rotary controller clockwise will move the focus + from top to bottom. Counterclockwise to move the focus from bottom to up. + Nudge any direction to leave this focus area. --> + <com.android.car.ui.FocusArea + android:id="@+id/card_with_default_focus" + android:background="@color/card_background_color" + android:layout_margin="16dp" + android:layout_width="@dimen/card_width" + android:layout_height="match_parent" + android:padding="@dimen/card_padding" + android:orientation="vertical"> + <TextView + android:layout_height="@dimen/description_height" + android:layout_width="match_parent" + android:text="@string/card_with_default_focus_text" /> + <Button + android:layout_width="match_parent" + android:layout_height="50dp" + android:onClick="onRotaryButtonClick" + android:tag="test_button" + android:text="Button" /> + <!-- TODO(b/154180719): Make this button the default focus in this FocusArea --> + <Button + android:layout_width="match_parent" + android:layout_height="50dp" + android:onClick="onRotaryButtonClick" + android:tag="test_button" + android:text="Button (Default)" /> + <Button + android:layout_width="match_parent" + android:layout_height="50dp" + android:onClick="onRotaryButtonClick" + android:tag="test_button" + android:text="Button" /> + <Button + android:layout_width="match_parent" + android:layout_height="50dp" + android:onClick="onRotaryButtonClick" + android:tag="test_button" + android:text="Button" /> + </com.android.car.ui.FocusArea> + + <!-- A FocusArea with buttons in a circle. The default focus should land on A. + Rotating clockwise moves the focus from A -> B -> C -> D -> E -> F -> G -> H, + and reverse counterclockwise. + Adding app:defaultFocus to A to make it the default focus on this card + Adding android:nextFocusForward is necessary to ensure the expected focus + order, without it, the focus will move from + G -> H -> F -> A -> E -> B -> D -> C. + Lastly, android:nextFocusForward is not added to H -> A, to avoid linking + the nodes in a circle. app:wrapAround="true" should be used instead. + --> + <!-- TODO(agathaman): add app:wrapAround to this card when b/155698037 is fixed --> + <!-- TODO(agathaman): add app:defaultFocus to this card when b/155698037 is fixed --> + <com.android.car.ui.FocusArea + android:id="@+id/card_that_wraps_around" + android:background="@color/card_background_color" + android:layout_margin="16dp" + android:layout_width="@dimen/card_width" + android:layout_height="match_parent" + android:padding="@dimen/card_padding" + android:orientation="vertical"> + <TextView + android:layout_height="@dimen/description_height" + android:layout_width="match_parent" + android:text="@string/card_that_wraps_around_text" /> + <androidx.constraintlayout.widget.ConstraintLayout + android:layout_width="match_parent" + android:layout_height="match_parent"> + <View + android:id="@+id/circle_center" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + app:layout_constraintBottom_toBottomOf="parent" + app:layout_constraintLeft_toLeftOf="parent" + app:layout_constraintRight_toRightOf="parent" + app:layout_constraintTop_toTopOf="parent" /> + <Button + android:id="@+id/button_a" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:nextFocusForward="@+id/button_b" + android:padding="20dp" + android:text="A" + app:layout_constraintCircle="@id/circle_center" + app:layout_constraintCircleAngle="0" + app:layout_constraintCircleRadius="90dp" + /> + <Button + android:id="@+id/button_b" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:nextFocusForward="@+id/button_c" + android:padding="20dp" + android:text="B" + app:layout_constraintCircle="@id/circle_center" + app:layout_constraintCircleAngle="45" + app:layout_constraintCircleRadius="90dp" + /> + <Button + android:id="@+id/button_c" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:nextFocusForward="@+id/button_d" + android:padding="20dp" + android:text="C" + app:layout_constraintCircle="@id/circle_center" + app:layout_constraintCircleAngle="90" + app:layout_constraintCircleRadius="90dp" + /> + <Button + android:id="@+id/button_d" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:nextFocusForward="@+id/button_e" + android:padding="20dp" + android:text="D" + app:layout_constraintCircle="@id/circle_center" + app:layout_constraintCircleAngle="135" + app:layout_constraintCircleRadius="90dp" + /> + <Button + android:id="@+id/button_e" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:nextFocusForward="@+id/button_f" + android:padding="20dp" + android:text="E" + app:layout_constraintCircle="@id/circle_center" + app:layout_constraintCircleAngle="180" + app:layout_constraintCircleRadius="90dp" + /> + <Button + android:id="@+id/button_f" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:nextFocusForward="@+id/button_g" + android:padding="20dp" + android:text="F" + app:layout_constraintCircle="@id/circle_center" + app:layout_constraintCircleAngle="225" + app:layout_constraintCircleRadius="90dp" + /> + <Button + android:id="@+id/button_g" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:nextFocusForward="@+id/button_h" + android:padding="20dp" + android:text="G" + app:layout_constraintCircle="@id/circle_center" + app:layout_constraintCircleAngle="270" + app:layout_constraintCircleRadius="90dp" + /> + <Button + android:id="@+id/button_h" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:padding="20dp" + android:text="H" + app:layout_constraintCircle="@id/circle_center" + app:layout_constraintCircleAngle="315" + app:layout_constraintCircleRadius="90dp" + /> + </androidx.constraintlayout.widget.ConstraintLayout> + </com.android.car.ui.FocusArea> + </LinearLayout> + </HorizontalScrollView> +</LinearLayout>
\ No newline at end of file diff --git a/RotaryPlayground/res/layout/rotary_direct_manipulation.xml b/RotaryPlayground/res/layout/rotary_direct_manipulation.xml new file mode 100644 index 0000000..6f6990e --- /dev/null +++ b/RotaryPlayground/res/layout/rotary_direct_manipulation.xml @@ -0,0 +1,83 @@ +<!-- + ~ Copyright (C) 2020 The Android Open Source Project + ~ + ~ Licensed under the Apache License, Version 2.0 (the "License"); + ~ you may not use this file except in compliance with the License. + ~ You may obtain a copy of the License at + ~ + ~ http://www.apache.org/licenses/LICENSE-2.0 + ~ + ~ Unless required by applicable law or agreed to in writing, software + ~ distributed under the License is distributed on an "AS IS" BASIS, + ~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + ~ See the License for the specific language governing permissions and + ~ limitations under the License. + --> +<LinearLayout + xmlns:android="http://schemas.android.com/apk/res/android" + android:layout_width="match_parent" + android:layout_height="match_parent" + android:orientation="horizontal"> + <!-- Split the screen in half horizontally. --> + + <!-- Two time pickers formatted differently. --> + <LinearLayout + android:layout_width="0dp" + android:layout_height="match_parent" + android:orientation="vertical" + android:layout_weight="1"> + <!-- Put each TimePicker into a separate FocusArea. A TimePicker has several focusable views + and it's difficult to move to another TimePicker via rotation. Let's wrap each TimePicker + with a FocusArea so that we can use nudge to move to another TimePicker. --> + <com.android.car.ui.FocusArea + android:layout_width="wrap_content" + android:layout_height="wrap_content"> + <TimePicker + android:id="@+id/spinner_time_picker" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:focusable="true" + android:timePickerMode="spinner"> + </TimePicker> + </com.android.car.ui.FocusArea> + <com.android.car.ui.FocusArea + android:layout_width="wrap_content" + android:layout_height="0dp" + android:layout_weight="1"> + <TimePicker + android:id="@+id/clock_time_picker" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:focusable="true" + android:timePickerMode="clock"> + </TimePicker> + </com.android.car.ui.FocusArea> + </LinearLayout> + + <!-- A seek bar, a radial time picker, and a custom DirectManipulationView. --> + <com.android.car.ui.FocusArea + android:layout_width="0dp" + android:layout_height="match_parent" + android:orientation="vertical" + android:layout_weight="1"> + <SeekBar + android:id="@+id/seek_bar" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:background="@drawable/seek_bar_background"> + </SeekBar> + <RadialTimePickerView + android:id="@+id/radial_time_picker" + android:layout_width="match_parent" + android:layout_height="0dp" + android:layout_weight="1" + android:focusable="true"> + </RadialTimePickerView> + <com.android.car.rotaryplayground.DirectManipulationView + android:id="@+id/direct_manipulation_view" + android:layout_width="match_parent" + android:layout_height="0dp" + android:layout_weight="1"> + </com.android.car.rotaryplayground.DirectManipulationView> + </com.android.car.ui.FocusArea> +</LinearLayout> diff --git a/RotaryPlayground/res/layout/rotary_grid.xml b/RotaryPlayground/res/layout/rotary_grid.xml new file mode 100644 index 0000000..7e9998d --- /dev/null +++ b/RotaryPlayground/res/layout/rotary_grid.xml @@ -0,0 +1,30 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + Copyright (C) 2020 The Android Open Source Project + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +--> + +<com.android.car.ui.FocusArea + 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="match_parent"> + + <com.android.car.ui.recyclerview.CarUiRecyclerView + android:id="@+id/rotary_grid_view" + app:layoutStyle="grid" + app:numOfColumns="3" + android:layout_width="match_parent" + android:layout_height="match_parent"/> +</com.android.car.ui.FocusArea> diff --git a/RotaryPlayground/res/layout/rotary_grid_item.xml b/RotaryPlayground/res/layout/rotary_grid_item.xml new file mode 100644 index 0000000..e532ec5 --- /dev/null +++ b/RotaryPlayground/res/layout/rotary_grid_item.xml @@ -0,0 +1,37 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + Copyright (C) 2020 The Android Open Source Project + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +--> + +<!-- This is the item layout for the rotary_grid.xml. The attribute, + android:focusable="true" is to allow the items to be focusable. + Alternatively, android:clickable="true" will also make the items + focusable automatically, but would mean the user could tap on the items. --> +<FrameLayout + xmlns:android="http://schemas.android.com/apk/res/android" + android:id="@+id/rotary_grid_item" + android:background="@color/grid_item_background_color" + android:focusable="true" + android:layout_width="match_parent" + android:layout_height="100dp" + android:layout_margin="10dp"> + + <TextView + android:id="@+id/rotary_grid_item_text" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:ellipsize="end" + android:maxLines="1"/> +</FrameLayout>
\ No newline at end of file diff --git a/RotaryPlayground/res/layout/rotary_menu.xml b/RotaryPlayground/res/layout/rotary_menu.xml new file mode 100644 index 0000000..df3ec19 --- /dev/null +++ b/RotaryPlayground/res/layout/rotary_menu.xml @@ -0,0 +1,59 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + ~ Copyright (C) 2020 The Android Open Source Project + ~ + ~ Licensed under the Apache License, Version 2.0 (the "License"); + ~ you may not use this file except in compliance with the License. + ~ You may obtain a copy of the License at + ~ + ~ http://www.apache.org/licenses/LICENSE-2.0 + ~ + ~ Unless required by applicable law or agreed to in writing, software + ~ distributed under the License is distributed on an "AS IS" BASIS, + ~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + ~ See the License for the specific language governing permissions and + ~ limitations under the License. + --> +<com.android.car.ui.FocusArea + xmlns:android="http://schemas.android.com/apk/res/android" + android:layout_width="match_parent" + android:layout_height="match_parent" + android:orientation="vertical"> + + <Button + android:id="@+id/cards" + android:layout_width="match_parent" + android:layout_height="0dp" + android:layout_weight="1" + android:text="Cards" /> + <Button + android:id="@+id/direct_manipulation" + android:layout_width="match_parent" + android:layout_height="0dp" + android:layout_weight="1" + android:text="Direct Manipulation" /> + <Button + android:id="@+id/sys_ui_direct_manipulation" + android:layout_width="match_parent" + android:layout_height="0dp" + android:layout_weight="1" + android:text="Sys UI Manipulation" /> + <Button + android:id="@+id/grid" + android:layout_width="match_parent" + android:layout_height="0dp" + android:layout_weight="1" + android:text="Grid" /> + <Button + android:id="@+id/notification" + android:layout_width="match_parent" + android:layout_height="0dp" + android:layout_weight="1" + android:text="Notification" /> + <Button + android:id="@+id/scroll" + android:layout_width="match_parent" + android:layout_height="0dp" + android:layout_weight="1" + android:text="Scroll" /> +</com.android.car.ui.FocusArea> diff --git a/RotaryPlayground/res/layout/rotary_notification.xml b/RotaryPlayground/res/layout/rotary_notification.xml new file mode 100644 index 0000000..998febe --- /dev/null +++ b/RotaryPlayground/res/layout/rotary_notification.xml @@ -0,0 +1,79 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + ~ Copyright (C) 2020 The Android Open Source Project + ~ + ~ Licensed under the Apache License, Version 2.0 (the "License"); + ~ you may not use this file except in compliance with the License. + ~ You may obtain a copy of the License at + ~ + ~ http://www.apache.org/licenses/LICENSE-2.0 + ~ + ~ Unless required by applicable law or agreed to in writing, software + ~ distributed under the License is distributed on an "AS IS" BASIS, + ~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + ~ See the License for the specific language governing permissions and + ~ limitations under the License. + --> + +<RelativeLayout + xmlns:android="http://schemas.android.com/apk/res/android" + android:layout_width="match_parent" + android:layout_height="match_parent"> + + <com.android.car.ui.FocusArea + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:layout_alignParentTop="true"> + <TextView + android:layout_width="0dp" + android:layout_height="@dimen/button_height" + android:layout_weight="1" + android:gravity="center" + android:text="Under HUN"/> + <Button + android:id="@+id/add_notification_button1" + android:layout_width="0dp" + android:layout_height="@dimen/button_height" + android:layout_weight="1" + android:layout_margin="5dp" + android:background="@color/button_background_color" + android:text="Add Notification"/> + <Button + android:id="@+id/clear_notification_button1" + android:layout_width="0dp" + android:layout_height="@dimen/button_height" + android:layout_weight="1" + android:layout_margin="5dp" + android:background="@color/button_background_color" + android:text="Clear Notification"/> + </com.android.car.ui.FocusArea> + + <com.android.car.ui.FocusArea + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:layout_alignParentBottom="true"> + <TextView + android:layout_width="0dp" + android:layout_height="@dimen/button_height" + android:layout_weight="1" + android:gravity="center" + android:text="Not Under HUN"/> + <Button + android:id="@+id/add_notification_button2" + android:layout_width="0dp" + android:layout_height="@dimen/button_height" + android:layout_weight="1" + android:layout_margin="5dp" + android:background="@color/button_background_color" + android:text="Add Notification"/> + <Button + android:id="@+id/clear_notification_button2" + android:layout_width="0dp" + android:layout_height="@dimen/button_height" + android:layout_weight="1" + android:layout_margin="5dp" + android:background="@color/button_background_color" + android:text="Clear Notification"/> + </com.android.car.ui.FocusArea> + +</RelativeLayout> diff --git a/RotaryPlayground/res/layout/rotary_scroll.xml b/RotaryPlayground/res/layout/rotary_scroll.xml new file mode 100644 index 0000000..4153ca1 --- /dev/null +++ b/RotaryPlayground/res/layout/rotary_scroll.xml @@ -0,0 +1,40 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + Copyright (C) 2020 The Android Open Source Project + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +--> + +<com.android.car.ui.FocusArea + xmlns:android="http://schemas.android.com/apk/res/android" + android:layout_width="match_parent" + android:layout_height="match_parent" + android:orientation="vertical"> + + <EditText + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:singleLine="true"/> + + <com.android.car.ui.recyclerview.CarUiRecyclerView + android:id="@+id/rotary_scroll_view" + android:layout_width="match_parent" + android:layout_height="0dp" + android:layout_weight="1"/> + + <EditText + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:singleLine="true"/> + +</com.android.car.ui.FocusArea> diff --git a/RotaryPlayground/res/layout/rotary_scroll_button.xml b/RotaryPlayground/res/layout/rotary_scroll_button.xml new file mode 100644 index 0000000..a223aa9 --- /dev/null +++ b/RotaryPlayground/res/layout/rotary_scroll_button.xml @@ -0,0 +1,35 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + Copyright (C) 2020 The Android Open Source Project + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +--> + +<LinearLayout + xmlns:android="http://schemas.android.com/apk/res/android" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:orientation="horizontal" > + <Button + android:layout_height="wrap_content" + android:layout_width="0dp" + android:layout_weight="1" + android:onClick="onRotaryButtonClick" + android:text="Button" /> + <Button + android:layout_height="wrap_content" + android:layout_width="0dp" + android:layout_weight="1" + android:onClick="onRotaryButtonClick" + android:text="Button" /> +</LinearLayout>
\ No newline at end of file diff --git a/RotaryPlayground/res/layout/rotary_scroll_text.xml b/RotaryPlayground/res/layout/rotary_scroll_text.xml new file mode 100644 index 0000000..d9d82f6 --- /dev/null +++ b/RotaryPlayground/res/layout/rotary_scroll_text.xml @@ -0,0 +1,32 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + Copyright (C) 2020 The Android Open Source Project + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +--> + +<LinearLayout + xmlns:android="http://schemas.android.com/apk/res/android" + android:layout_width="match_parent" + android:layout_height="wrap_content"> + + <TextView + android:id="@+id/scroll_text_view" + android:layout_height="wrap_content" + android:layout_width="match_parent" + android:background="@color/scroll_text_background_color" + android:layout_marginTop="@dimen/margin" + android:layout_marginBottom="@dimen/margin" + android:padding="@dimen/padding" + android:text="@string/lorem" /> +</LinearLayout>
\ No newline at end of file diff --git a/RotaryPlayground/res/layout/rotary_sys_ui_direct_manipulation.xml b/RotaryPlayground/res/layout/rotary_sys_ui_direct_manipulation.xml new file mode 100644 index 0000000..9e30a1d --- /dev/null +++ b/RotaryPlayground/res/layout/rotary_sys_ui_direct_manipulation.xml @@ -0,0 +1,86 @@ +<!-- + ~ Copyright (C) 2020 The Android Open Source Project + ~ + ~ Licensed under the Apache License, Version 2.0 (the "License"); + ~ you may not use this file except in compliance with the License. + ~ You may obtain a copy of the License at + ~ + ~ http://www.apache.org/licenses/LICENSE-2.0 + ~ + ~ Unless required by applicable law or agreed to in writing, software + ~ distributed under the License is distributed on an "AS IS" BASIS, + ~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + ~ See the License for the specific language governing permissions and + ~ limitations under the License. + --> +<!-- +The purpose of this screen is to demonstrate the direct manipulation capabilities in a +system window. Not all widgets in a system window may support direct manipulation. Some +of the widgets in this page support direct manipulation and some are left as unsupported for +demonstrating the relevant behavior. + +Note that nudges are not supported in direct manipulation mode for system windows. + +The behavior of the UI on this page is intended to be tested while setting the value of +RotaryController#TREAT_APP_WINDOW_AS_SYSTEM_WINDOW constant to true. +--> +<LinearLayout + xmlns:android="http://schemas.android.com/apk/res/android" + android:layout_width="match_parent" + android:layout_height="match_parent" + android:orientation="horizontal"> + + <!-- Widgets in the first half of the screen support Direct Manipulation and + those in the second half don't. This is set programmatically. --> + <com.android.car.ui.FocusArea + android:layout_width="0dp" + android:layout_height="match_parent" + android:layout_weight="1" + android:orientation="vertical"> + <TextView + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:gravity="center" + android:text="@string/sys_ui_supports_dm"> + </TextView> + <SeekBar + android:id="@+id/direct_manipulation_supported_seek_bar" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:background="@drawable/seek_bar_background"> + </SeekBar> + <RadialTimePickerView + android:id="@+id/direct_manipulation_supported_radial_time_picker" + android:layout_width="match_parent" + android:layout_height="0dp" + android:layout_weight="1" + android:focusable="true"> + </RadialTimePickerView> + </com.android.car.ui.FocusArea> + + <com.android.car.ui.FocusArea + android:layout_width="0dp" + android:layout_height="match_parent" + android:layout_weight="1" + android:orientation="vertical"> + <TextView + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:gravity="center" + android:text="@string/sys_ui_does_not_support_dm"> + </TextView> + <SeekBar + android:id="@+id/direct_manipulation_unsupported_seek_bar" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:background="@drawable/seek_bar_background"> + </SeekBar> + <RadialTimePickerView + android:id="@+id/direct_manipulation_unsupported_radial_time_picker" + android:layout_width="match_parent" + android:layout_height="0dp" + android:layout_weight="1" + android:focusable="true"> + </RadialTimePickerView> + </com.android.car.ui.FocusArea> +</LinearLayout> diff --git a/RotaryPlayground/res/values/arrays.xml b/RotaryPlayground/res/values/arrays.xml new file mode 100644 index 0000000..6c68e6e --- /dev/null +++ b/RotaryPlayground/res/values/arrays.xml @@ -0,0 +1,74 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + ~ Copyright (C) 2020 The Android Open Source Project + ~ + ~ Licensed under the Apache License, Version 2.0 (the "License"); + ~ you may not use this file except in compliance with the License. + ~ You may obtain a copy of the License at + ~ + ~ http://www.apache.org/licenses/LICENSE-2.0 + ~ + ~ Unless required by applicable law or agreed to in writing, software + ~ distributed under the License is distributed on an "AS IS" BASIS, + ~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + ~ See the License for the specific language governing permissions and + ~ limitations under the License + --> +<resources> + <!-- List of greetings to display on button clicks --> + <string-array name="greetings" translatable="false"> + <item>Hello!</item> + <item>Bonjour!</item> + <item>Hola!</item> + <item>Zdravstvuyte!</item> + <item>Ni Hao!</item> + <item>Salve!</item> + <item>Konnichiwa!</item> + <item>Guten Tag!</item> + <item>Ola!</item> + <item>Anyoung haseyo!</item> + <item>Asalaam alaikum!</item> + <item>Goddag!</item> + <item>Shikamoo!</item> + <item>Goedendag!</item> + <item>Yassas!</item> + <item>Dzien dobry!</item> + <item>Selamat siang!</item> + <item>Namaste, Namaskar!</item> + <item>Merhaba!</item> + <item>Shalom!</item> + </string-array> + + <!-- Array used for creating items in the scroll demo. The scroll demos creates 2 types of + items: (1) A view with two buttons (fixed height) and (2) a textview with varying + height. Items here with '0' indicates the buttons item and items with value > 0 indicates + the text item where the integer value is the height of the textview. --> + <integer-array name="scroll_item_heights"> + <item>240</item> + <item>0</item> + <item>60</item> + <item>0</item> + <item>60</item> + <item>60</item> + <item>0</item> + <item>120</item> + <item>0</item> + <item>120</item> + <item>120</item> + <item>0</item> + <item>180</item> + <item>0</item> + <item>180</item> + <item>180</item> + <item>0</item> + <item>240</item> + <item>0</item> + <item>240</item> + <item>240</item> + <item>240</item> + <item>0</item> + <item>240</item> + <item>240</item> + <item>240</item> + </integer-array> +</resources>
\ No newline at end of file diff --git a/RotaryPlayground/res/values/colors.xml b/RotaryPlayground/res/values/colors.xml new file mode 100644 index 0000000..87480a0 --- /dev/null +++ b/RotaryPlayground/res/values/colors.xml @@ -0,0 +1,24 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + ~ Copyright (C) 2020 The Android Open Source Project + ~ + ~ Licensed under the Apache License, Version 2.0 (the "License"); + ~ you may not use this file except in compliance with the License. + ~ You may obtain a copy of the License at + ~ + ~ http://www.apache.org/licenses/LICENSE-2.0 + ~ + ~ Unless required by applicable law or agreed to in writing, software + ~ distributed under the License is distributed on an "AS IS" BASIS, + ~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + ~ See the License for the specific language governing permissions and + ~ limitations under the License + --> +<resources> + <color name="card_background_color">#37393d</color> + <color name="card_disabled_background_color">#61646b</color> + <color name="grid_item_background_color">#006666</color> + <color name="button_background_color">#660000</color> + <color name="button_disabled_background_color">#61646b</color> + <color name="scroll_text_background_color">#61646b</color> +</resources>
\ No newline at end of file diff --git a/RotaryPlayground/res/values/dimens.xml b/RotaryPlayground/res/values/dimens.xml new file mode 100644 index 0000000..02a5406 --- /dev/null +++ b/RotaryPlayground/res/values/dimens.xml @@ -0,0 +1,29 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + ~ Copyright (C) 2020 The Android Open Source Project + ~ + ~ Licensed under the Apache License, Version 2.0 (the "License"); + ~ you may not use this file except in compliance with the License. + ~ You may obtain a copy of the License at + ~ + ~ http://www.apache.org/licenses/LICENSE-2.0 + ~ + ~ Unless required by applicable law or agreed to in writing, software + ~ distributed under the License is distributed on an "AS IS" BASIS, + ~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + ~ See the License for the specific language governing permissions and + ~ limitations under the License + --> +<resources> + <!-- common values --> + <dimen name="padding">16dp</dimen> + <dimen name="margin">16dp</dimen> + <!-- Rotary Menu values --> + <dimen name="menu_width">200dp</dimen> + <!-- Card example values --> + <dimen name="card_width">400dp</dimen> + <dimen name="card_padding">20dp</dimen> + <dimen name="description_height">200dp</dimen> + <!-- Notification values --> + <dimen name="button_height">70dp</dimen> +</resources>
\ No newline at end of file diff --git a/RotaryPlayground/res/values/ids.xml b/RotaryPlayground/res/values/ids.xml new file mode 100644 index 0000000..083862e --- /dev/null +++ b/RotaryPlayground/res/values/ids.xml @@ -0,0 +1,23 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + ~ Copyright (C) 2020 The Android Open Source Project + ~ + ~ Licensed under the Apache License, Version 2.0 (the "License"); + ~ you may not use this file except in compliance with the License. + ~ You may obtain a copy of the License at + ~ + ~ http://www.apache.org/licenses/LICENSE-2.0 + ~ + ~ Unless required by applicable law or agreed to in writing, software + ~ distributed under the License is distributed on an "AS IS" BASIS, + ~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + ~ See the License for the specific language governing permissions and + ~ limitations under the License. + --> +<resources> + <!-- + Used for storing and retrieving the background of a View in order to change it to an + alternative background but have the ability to restore it back to its original look. + --> + <item type="id" name="saved_background_tag"/> +</resources>
\ No newline at end of file diff --git a/RotaryPlayground/res/values/strings.xml b/RotaryPlayground/res/values/strings.xml new file mode 100644 index 0000000..7720216 --- /dev/null +++ b/RotaryPlayground/res/values/strings.xml @@ -0,0 +1,42 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + ~ Copyright (C) 2020 The Android Open Source Project + ~ + ~ Licensed under the Apache License, Version 2.0 (the "License"); + ~ you may not use this file except in compliance with the License. + ~ You may obtain a copy of the License at + ~ + ~ http://www.apache.org/licenses/LICENSE-2.0 + ~ + ~ Unless required by applicable law or agreed to in writing, software + ~ distributed under the License is distributed on an "AS IS" BASIS, + ~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + ~ See the License for the specific language governing permissions and + ~ limitations under the License. + --> +<resources> + <string name="app_name" translatable="false">Rotary Playground</string> + + <!-- Texts for card examples --> + <string name="card_normal_text" translatable="false"> + The focus should land on the first button upon entering this card. Rotating the rotary controller will move the focus up and down. The disabled button should not be focusable. + </string> + <string name="card_disabled_text" translatable="false"> + Nothing in this card should be focusable because all the buttons are disabled. + </string> + <string name="card_with_default_focus_text" translatable="false"> + The focus should land on the second button upon entering this card. + </string> + <string name="card_that_wraps_around_text" translatable="false"> + The focus should move clockwise when rotating clockwise and similarly for counterclockwise. Focus also wraps around the elements (H -> A clockwise and A -> H counterclockwise). + </string> + <string name="lorem" translatable="false"> + Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nulla et velit finibus, tempor ipsum vel, pellentesque lorem. Suspendisse congue urna pulvinar, congue dolor in, eleifend orci. Nam egestas sodales sapien. Integer sit amet ligula in ex commodo semper. Phasellus ac facilisis ligula. Ut mollis risus eu nunc consequat, eu molestie dolor maximus. Vestibulum ante ipsum primis in faucibus orci luctus et ultrices posuere cubilia curae; Donec commodo lectus non est lacinia accumsan. Nulla tristique cursus iaculis. Sed in hendrerit nulla, vel faucibus mi. Aenean placerat turpis eros, non sagittis enim viverra id. Vestibulum vel rutrum est, eu consequat felis. Maecenas ac suscipit ante. Fusce dapibus ut libero blandit vehicula. Vivamus vel ipsum condimentum, maximus tellus nec, rutrum lorem. + </string> + <string name="sys_ui_supports_dm" translatable="true"> + Supports Direct Manipulation Mode + </string> + <string name="sys_ui_does_not_support_dm" translatable="true"> + Doesn\'t support Direct Manipulation Mode + </string> +</resources> diff --git a/RotaryPlayground/res/values/themes.xml b/RotaryPlayground/res/values/themes.xml new file mode 100644 index 0000000..02a0be9 --- /dev/null +++ b/RotaryPlayground/res/values/themes.xml @@ -0,0 +1,26 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + ~ Copyright (C) 2020 The Android Open Source Project + ~ + ~ Licensed under the Apache License, Version 2.0 (the "License"); + ~ you may not use this file except in compliance with the License. + ~ You may obtain a copy of the License at + ~ + ~ http://www.apache.org/licenses/LICENSE-2.0 + ~ + ~ Unless required by applicable law or agreed to in writing, software + ~ distributed under the License is distributed on an "AS IS" BASIS, + ~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + ~ See the License for the specific language governing permissions and + ~ limitations under the License + --> +<resources> + <style name="Theme.App" parent="android:Theme.DeviceDefault"> + <item name="android:buttonStyle">@style/ButtonStyle</item> + </style> + + <style name="ButtonStyle" parent="android:Widget.Button"> + <item name="android:background">@android:color/transparent</item> + <item name="android:textColor">@android:color/white</item> + </style> +</resources>
\ No newline at end of file diff --git a/RotaryPlayground/src/com/android/car/rotaryplayground/DirectManipulationHandler.java b/RotaryPlayground/src/com/android/car/rotaryplayground/DirectManipulationHandler.java new file mode 100644 index 0000000..fc06754 --- /dev/null +++ b/RotaryPlayground/src/com/android/car/rotaryplayground/DirectManipulationHandler.java @@ -0,0 +1,152 @@ +/* + * Copyright (C) 2020 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.car.rotaryplayground; + +import android.util.Log; +import android.view.KeyEvent; +import android.view.MotionEvent; +import android.view.View; +import android.view.ViewGroup; + +import androidx.annotation.Nullable; +import androidx.core.util.Preconditions; + +/** + * A {@link View.OnKeyListener} and {@link View.OnGenericMotionListener} that adds a + * "Direct Manipulation" mode to any {@link View} that uses it. + * <p> + * Direct Manipulation mode in the Rotary context is a mode in which the user can use the + * Rotary controls to manipulate and change the UI elements they are interacting with rather + * than navigate through the entire UI. + * <p> + * Treats {@link KeyEvent#KEYCODE_DPAD_CENTER} as the signal to enter Direct Manipulation + * mode, and {@link KeyEvent#KEYCODE_BACK} as the signal to exit, and keeps track of which + * mode the {@link View} using it is currently in. + * <p> + * When in Direct Manipulation mode, it delegates to {@code mDirectionalDelegate} + * for handling nudge behavior and {@code mMotionDelegate} for rotation behavior. Generally + * it is expected that in Direct Manipulation mode, nudges are used for navigation and + * rotation is used for "manipulating" the value of the selected {@link View}. + * <p> + * To reduce boilerplate, this class provides "no op" nudge and rotation behavior if + * no {@link View.OnKeyListener} or {@link View.OnGenericMotionListener} are provided as + * delegates for tackling the relevant events. + * <p> + * Allows {@link View}s that are within a {@link ViewGroup} to provide a link to their + * ancestor {@link ViewGroup} from which Direct Manipulation mode was first enabled. That way + * when the user finally exits Direct Manipulation mode, both objects are restored to their + * original state. + */ +public class DirectManipulationHandler implements View.OnKeyListener, + View.OnGenericMotionListener { + + private final DirectManipulationState mDirectManipulationMode; + private final View.OnKeyListener mNudgeDelegate; + private final View.OnGenericMotionListener mRotationDelegate; + + /** + * A builder for {@link DirectManipulationHandler}. + */ + public static class Builder { + private final DirectManipulationState mDmState; + private View.OnKeyListener mNudgeDelegate; + private View.OnGenericMotionListener mRotationDelegate; + + public Builder(DirectManipulationState dmState) { + Preconditions.checkNotNull(dmState); + this.mDmState = dmState; + } + + public Builder setNudgeHandler(View.OnKeyListener directionalDelegate) { + Preconditions.checkNotNull(directionalDelegate); + this.mNudgeDelegate = directionalDelegate; + return this; + } + + public Builder setRotationHandler(View.OnGenericMotionListener motionDelegate) { + Preconditions.checkNotNull(motionDelegate); + this.mRotationDelegate = motionDelegate; + return this; + } + + public DirectManipulationHandler build() { + if (mNudgeDelegate == null && mRotationDelegate == null) { + throw new IllegalStateException("At least one delegate must be provided."); + } + return new DirectManipulationHandler(mDmState, mNudgeDelegate, mRotationDelegate); + } + } + + private DirectManipulationHandler(DirectManipulationState dmState, + @Nullable View.OnKeyListener nudgeDelegate, + @Nullable View.OnGenericMotionListener rotationDelegate) { + Preconditions.checkNotNull(dmState); + mDirectManipulationMode = dmState; + mNudgeDelegate = nudgeDelegate; + mRotationDelegate = rotationDelegate; + } + + @Override + public boolean onKey(View view, int keyCode, KeyEvent keyEvent) { + boolean isActionUp = keyEvent.getAction() == KeyEvent.ACTION_UP; + Log.d(L.TAG, "View: " + view + " is handling " + keyCode + + " and action " + keyEvent.getAction() + + " direct manipulation mode is " + + (mDirectManipulationMode.isActive() ? "active" : "inactive")); + + switch (keyCode) { + case KeyEvent.KEYCODE_DPAD_CENTER: + // If not yet in Direct Manipulation mode, switch to that mode. + + if (!mDirectManipulationMode.isActive() && isActionUp) { + mDirectManipulationMode.enable(view); + } + return true; + case KeyEvent.KEYCODE_BACK: + // If in Direct Manipulation mode, exit, and clean up state. + if (mDirectManipulationMode.isActive() && isActionUp) { + mDirectManipulationMode.disable(); + } + return true; + default: + // This handler is only responsible for behavior during Direct Manipulation + // mode. When the mode is disabled, ignore events. + if (!mDirectManipulationMode.isActive()) { + return false; + } + // If no delegate present, silently consume the events. + if (mNudgeDelegate == null) { + return true; + } + return mNudgeDelegate.onKey(view, keyCode, keyEvent); + } + } + + @Override + public boolean onGenericMotion(View v, MotionEvent event) { + // This handler is only responsible for behavior during Direct Manipulation + // mode. When the mode is disabled, ignore events. + if (!mDirectManipulationMode.isActive()) { + return false; + } + // If no delegate present, silently consume the events. + if (mRotationDelegate == null) { + return true; + } + return mRotationDelegate.onGenericMotion(v, event); + } +} diff --git a/RotaryPlayground/src/com/android/car/rotaryplayground/DirectManipulationState.java b/RotaryPlayground/src/com/android/car/rotaryplayground/DirectManipulationState.java new file mode 100644 index 0000000..05d236b --- /dev/null +++ b/RotaryPlayground/src/com/android/car/rotaryplayground/DirectManipulationState.java @@ -0,0 +1,98 @@ +/* + * Copyright (C) 2020 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.car.rotaryplayground; + +import android.graphics.Color; +import android.view.View; +import android.view.ViewGroup; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.android.car.ui.utils.DirectManipulationHelper; + +/** + * Keeps track of the state of "direct manipulation" Rotary mode for this application window by + * tracking a reference to the {@link View} from which the user first enters into "direct + * manipulation" mode. + * + * <p>See {@link DirectManipulationHandler} for a definition of "direct manipulation". + */ +public class DirectManipulationState { + + + /** Background color of a view when it's in direct manipulation mode. */ + private static final int BACKGROUND_COLOR_IN_DIRECT_MANIPULATION_MODE = Color.BLUE; + + /** Background color of a view when it's not in direct manipulation mode. */ + private static final int BACKGROUND_COLOR_NOT_IN_DIRECT_MANIPULATION_MODE = Color.TRANSPARENT; + + /** The view that is in direct manipulation mode, or null if none. */ + @Nullable private View mViewInDirectManipulationMode; + + private void setStartingView(@Nullable View view) { + mViewInDirectManipulationMode = view; + } + + /** + * Returns true if Direct Manipulation mode is active, false otherwise. + */ + public boolean isActive() { + return mViewInDirectManipulationMode != null; + } + + /** + * Enables Direct Manipulation mode, and keeps track of {@code view} as the starting point + * of this transition. + * <p> + * We generally want to give some kind of visual indication that this change has happened. In + * this example we change the background color of {@code view}. + * + * @param view - the {@link View} from which we entered into Direct Manipulation mode. + */ + public void enable(@NonNull View view) { + /* + * A more robust approach would be to fetch the current background color from + * the view object and store it back onto the View itself using the {@link + * View#setTag(int, java.lang.Object)} API. This could then be fetched back + * and used to restore the background color without needing to keep a constant + * reference to the color here which could fall out of sync with the xml files. + */ + view.setBackgroundColor(BACKGROUND_COLOR_IN_DIRECT_MANIPULATION_MODE); + DirectManipulationHelper.enableDirectManipulationMode(view, /* enable= */ true); + setStartingView(view); + } + + /** + * Disables Direct Manipulation mode and restores any visual indicators for the {@link View} + * from which we entered into Direct Manipulation mode. + */ + public void disable() { + mViewInDirectManipulationMode.setBackgroundColor( + BACKGROUND_COLOR_NOT_IN_DIRECT_MANIPULATION_MODE); + DirectManipulationHelper.enableDirectManipulationMode( + mViewInDirectManipulationMode, /* enable= */ false); + // For ViewGroup objects, restore descendant focusability to FOCUS_BLOCK_DESCENDANTS so + // during non-Direct Manipulation mode, aka, general rotary navigation, we don't go + // through the individual inner UI elements. + if (mViewInDirectManipulationMode instanceof ViewGroup) { + ViewGroup viewGroup = (ViewGroup) mViewInDirectManipulationMode; + viewGroup.setDescendantFocusability(ViewGroup.FOCUS_BLOCK_DESCENDANTS); + } + setStartingView(null); + } +} diff --git a/RotaryPlayground/src/com/android/car/rotaryplayground/DirectManipulationView.java b/RotaryPlayground/src/com/android/car/rotaryplayground/DirectManipulationView.java new file mode 100644 index 0000000..733de87 --- /dev/null +++ b/RotaryPlayground/src/com/android/car/rotaryplayground/DirectManipulationView.java @@ -0,0 +1,210 @@ +/* + * Copyright (C) 2020 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.car.rotaryplayground; + +import static java.lang.Math.min; + +import android.content.Context; +import android.graphics.Canvas; +import android.graphics.Color; +import android.graphics.Paint; +import android.util.AttributeSet; +import android.view.KeyEvent; +import android.view.MotionEvent; +import android.view.View; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +/** + * A {@link View} used to demonstrate direct manipulation mode. + * <p> + * This view draws nothing but a circle. It provides APIs to change the center and the radius of the + * circle. + */ +public class DirectManipulationView extends View { + + /** + * How many pixels do we want to move the center of the circle horizontally from its initial + * position. + */ + private float mDeltaX; + /** + * How many pixels do we want to move the center of the circle vertically from its initial + * position. + */ + private float mDeltaY; + /** How many pixels do we want change the radius of the circle from its initial radius. */ + private float mDeltaRadius; + + private Paint mPaint; + + public DirectManipulationView(Context context) { + super(context); + init(); + } + + public DirectManipulationView(Context context, @Nullable AttributeSet attrs) { + super(context, attrs); + init(); + } + + public DirectManipulationView(Context context, @Nullable AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + init(); + } + + public DirectManipulationView(Context context, @Nullable AttributeSet attrs, int defStyleAttr, + int defStyleRes) { + super(context, attrs, defStyleAttr, defStyleRes); + init(); + } + + @Override + protected void onDraw(Canvas canvas) { + super.onDraw(canvas); + + // Draw the circle. Initially the circle is in the center of the canvas, and its radius is + // min(getWidth(), getHeight()) / 4. We need to translate it and scale it. + canvas.drawCircle( + /* cx= */getWidth() / 2 + mDeltaX, + /* cy= */getHeight() / 2 + mDeltaY, + /* radius= */min(getWidth(), getHeight()) / 4 + mDeltaRadius, + mPaint); + + } + + /** + * Moves the center of the circle by {@code dx} horizontally and by {@code dy} vertically, then + * redraws it. + */ + void move(float dx, float dy) { + mDeltaX += dx; + mDeltaY += dy; + invalidate(); + } + + /** Changes the radius of the circle by {@code dr} then redraws it. */ + void resizeCircle(float dr) { + mDeltaRadius += dr; + invalidate(); + } + + private void init() { + // The view must be focusable to enter direct manipulation mode. + setFocusable(View.FOCUSABLE); + + // Set up paint with color and stroke styles. + mPaint = new Paint(); + mPaint.setColor(Color.GREEN); + mPaint.setAntiAlias(true); + mPaint.setStrokeWidth(5); + mPaint.setStyle(Paint.Style.FILL_AND_STROKE); + mPaint.setStrokeJoin(Paint.Join.ROUND); + mPaint.setStrokeCap(Paint.Cap.ROUND); + } + + /** + * A {@link View.OnKeyListener} for handling Direct Manipulation rotary nudge behavior + * for a {@link DirectManipulationView}. + * <p> + * This handler expects that it is being used in Direct Manipulation mode, i.e. as a directional + * delegate through a {@link DirectManipulationHandler} which can invoke it at the + * appropriate times. + * <p> + * Moves the circle drawn in the {@link DirectManipulationView} in the relevant direction for + * following {@link KeyEvent}s: + * <ul> + * <li>{@link KeyEvent#KEYCODE_DPAD_UP} + * <li>{@link KeyEvent#KEYCODE_DPAD_DOWN} + * <li>{@link KeyEvent#KEYCODE_DPAD_LEFT} + * <li>{@link KeyEvent#KEYCODE_DPAD_RIGHT} + * </ul> + */ + static class NudgeHandler implements View.OnKeyListener { + + /** How many pixels do we want to move the {@link DirectManipulationView} per nudge. */ + private static final float DIRECT_MANIPULATION_VIEW_PX_PER_NUDGE = 10f; + + @Override + public boolean onKey(View v, int keyCode, KeyEvent keyEvent) { + if (keyEvent.getAction() != KeyEvent.ACTION_UP) { + return true; + } + + if (v instanceof DirectManipulationView) { + DirectManipulationView dmv = (DirectManipulationView) v; + handleNudgeEvent(dmv, keyCode); + return true; + } + + throw new UnsupportedOperationException("NudgeHandler shouldn't be registered " + + "as a listener on a view other than a DirectManipulationView."); + } + + /** Moves the circle of the DirectManipulationView when the controller nudges. */ + private void handleNudgeEvent(@NonNull DirectManipulationView dmv, int keyCode) { + switch (keyCode) { + case KeyEvent.KEYCODE_DPAD_UP: + dmv.move(0f, -DIRECT_MANIPULATION_VIEW_PX_PER_NUDGE); + return; + case KeyEvent.KEYCODE_DPAD_DOWN: + dmv.move(0f, DIRECT_MANIPULATION_VIEW_PX_PER_NUDGE); + return; + case KeyEvent.KEYCODE_DPAD_LEFT: + dmv.move(-DIRECT_MANIPULATION_VIEW_PX_PER_NUDGE, 0f); + return; + case KeyEvent.KEYCODE_DPAD_RIGHT: + dmv.move(DIRECT_MANIPULATION_VIEW_PX_PER_NUDGE, 0f); + return; + default: + throw new IllegalArgumentException("Invalid keycode: " + keyCode); + } + } + } + + /** + * A {@link View.OnGenericMotionListener} for handling Direct Manipulation rotation events for + * a {@link DirectManipulationView}. It does so by increasing or decreasing the radius of + * the circle drawn depending on the direction of rotation. + */ + static class RotationHandler implements View.OnGenericMotionListener { + + /** + * How many pixels do we want to change the radius of the circle in the + * {@link DirectManipulationView} for a rotation. + */ + private static final float DIRECT_MANIPULATION_VIEW_PX_PER_ROTATION = 10f; + + @Override + public boolean onGenericMotion(View v, MotionEvent event) { + if (v instanceof DirectManipulationView) { + handleRotateEvent( + (DirectManipulationView) v, + event.getAxisValue(MotionEvent.AXIS_SCROLL)); + return true; + } + + throw new UnsupportedOperationException("RotationHandler shouldn't be registered " + + "as a listener on a view other than a DirectManipulationView."); + } + + /** Resizes the circle of the DirectManipulationView when the controller rotates. */ + private void handleRotateEvent(@NonNull DirectManipulationView dmv, float scroll) { + dmv.resizeCircle(DIRECT_MANIPULATION_VIEW_PX_PER_ROTATION * scroll); + } + } +} diff --git a/RotaryPlayground/src/com/android/car/rotaryplayground/HeadsUpNotificationFragment.java b/RotaryPlayground/src/com/android/car/rotaryplayground/HeadsUpNotificationFragment.java new file mode 100644 index 0000000..0744ac0 --- /dev/null +++ b/RotaryPlayground/src/com/android/car/rotaryplayground/HeadsUpNotificationFragment.java @@ -0,0 +1,77 @@ +/* + * Copyright (C) 2020 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.car.rotaryplayground; + +import android.app.Notification; +import android.app.NotificationChannel; +import android.app.NotificationManager; +import android.app.PendingIntent; +import android.content.Intent; +import android.os.Bundle; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; + +import androidx.annotation.Nullable; +import androidx.fragment.app.Fragment; + +public class HeadsUpNotificationFragment extends Fragment { + private static final String NOTIFICATION_CHANNEL_ID = "rotary_notification"; + private static final int NOTIFICATION_ID = 1; + + @Override + public View onCreateView(LayoutInflater inflater, @Nullable ViewGroup container, + @Nullable Bundle savedInstanceState) { + View view = inflater.inflate(R.layout.rotary_notification, container, false); + NotificationManager notificationManager = + getContext().getSystemService(NotificationManager.class); + notificationManager.createNotificationChannel( + new NotificationChannel(NOTIFICATION_CHANNEL_ID, "Rotary Playground", + NotificationManager.IMPORTANCE_HIGH)); + view.findViewById(R.id.add_notification_button1).setOnClickListener( + v -> notificationManager.notify(NOTIFICATION_ID, createNotification())); + view.findViewById(R.id.clear_notification_button1).setOnClickListener( + v -> notificationManager.cancel(NOTIFICATION_ID)); + view.findViewById(R.id.add_notification_button2).setOnClickListener( + v -> notificationManager.notify(NOTIFICATION_ID, createNotification())); + view.findViewById(R.id.clear_notification_button2).setOnClickListener( + v -> notificationManager.cancel(NOTIFICATION_ID)); + + return view; + } + + /** + * Creates a notification with CATEGORY_CALL in a channel with IMPORTANCE_HIGH. This will + * produce a heads-up notification even for non-system apps that aren't privileged and aren't + * signed with the platform key. The notification includes three actions which appear as buttons + * in the HUN. + */ + private Notification createNotification() { + Intent intent = new Intent(getContext(), RotaryActivity.class); + PendingIntent pendingIntent = PendingIntent.getActivity(getContext(), 0, intent, 0); + return new Notification.Builder(getContext(), NOTIFICATION_CHANNEL_ID) + .setContentTitle("Example heads-up notification") + .setContentText("Try nudging up to HUN") + .setSmallIcon(R.drawable.ic_launcher) + .addAction(new Notification.Action.Builder(null, "Action1", pendingIntent).build()) + .addAction(new Notification.Action.Builder(null, "Action2", pendingIntent).build()) + .addAction(new Notification.Action.Builder(null, "Action3", pendingIntent).build()) + .setColor(getContext().getColor(android.R.color.holo_red_light)) + .setCategory(Notification.CATEGORY_CALL) + .build(); + } +} diff --git a/RotaryPlayground/src/com/android/car/rotaryplayground/L.java b/RotaryPlayground/src/com/android/car/rotaryplayground/L.java new file mode 100644 index 0000000..d970f6d --- /dev/null +++ b/RotaryPlayground/src/com/android/car/rotaryplayground/L.java @@ -0,0 +1,24 @@ +/* + * Copyright (C) 2020 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.car.rotaryplayground; + +/** + * A class to facilitate app-wide logging. + */ +public class L { + public static final String TAG = "RotaryPlayground"; +} diff --git a/RotaryPlayground/src/com/android/car/rotaryplayground/RotaryActivity.java b/RotaryPlayground/src/com/android/car/rotaryplayground/RotaryActivity.java new file mode 100644 index 0000000..eada0cf --- /dev/null +++ b/RotaryPlayground/src/com/android/car/rotaryplayground/RotaryActivity.java @@ -0,0 +1,68 @@ +/* + * Copyright (C) 2020 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.car.rotaryplayground; + +import android.os.Bundle; +import android.util.Log; +import android.view.View; +import android.widget.Toast; + +import androidx.fragment.app.Fragment; +import androidx.fragment.app.FragmentActivity; + +import java.util.Random; + +/** + * The main activity for Rotary Playground + * + * This activity starts the menu fragment and handles event calls (e.g. android:onClick) from + * elements in fragments. + */ +public class RotaryActivity extends FragmentActivity { + + private static final String TAG = "RotaryActivity"; + + private final Random mRandom = new Random(); + + private Fragment mMenuFragment = null; + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setContentView(R.layout.rotary_activity); + showMenuFragment(); + } + + /** Event handler for button clicks. */ + public void onRotaryButtonClick(View v) { + final String[] greetings = getResources().getStringArray(R.array.greetings); + showToast(greetings[mRandom.nextInt(greetings.length)]); + } + + private void showMenuFragment() { + if (mMenuFragment == null) { + mMenuFragment = new RotaryMenu(); + } + getSupportFragmentManager().beginTransaction() + .replace(R.id.rotary_menu, mMenuFragment) + .commit(); + } + + private void showToast(String message) { + Log.d(TAG, "showToast -> " + message); + Toast.makeText(this, message, Toast.LENGTH_SHORT).show(); + } +}
\ No newline at end of file diff --git a/RotaryPlayground/src/com/android/car/rotaryplayground/RotaryCards.java b/RotaryPlayground/src/com/android/car/rotaryplayground/RotaryCards.java new file mode 100644 index 0000000..28f34a1 --- /dev/null +++ b/RotaryPlayground/src/com/android/car/rotaryplayground/RotaryCards.java @@ -0,0 +1,35 @@ +/* + * Copyright (C) 2020 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.car.rotaryplayground; + +import android.os.Bundle; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import androidx.annotation.Nullable; +import androidx.fragment.app.Fragment; + +/** Fragment to demo a layout with cards that are FocusArea containers. */ +public class RotaryCards extends Fragment { + + @Override + public View onCreateView(LayoutInflater inflater, @Nullable ViewGroup container, + @Nullable Bundle savedInstanceState) { + View view = inflater.inflate(R.layout.rotary_cards, container, false); + return view; + } +} diff --git a/RotaryPlayground/src/com/android/car/rotaryplayground/RotaryDirectManipulationWidgets.java b/RotaryPlayground/src/com/android/car/rotaryplayground/RotaryDirectManipulationWidgets.java new file mode 100644 index 0000000..9184597 --- /dev/null +++ b/RotaryPlayground/src/com/android/car/rotaryplayground/RotaryDirectManipulationWidgets.java @@ -0,0 +1,339 @@ +/* + * Copyright (C) 2020 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.car.rotaryplayground; + +import android.os.Bundle; +import android.view.KeyEvent; +import android.view.LayoutInflater; +import android.view.MotionEvent; +import android.view.View; +import android.view.ViewGroup; +import android.view.ViewParent; +import android.view.accessibility.AccessibilityNodeInfo; +import android.widget.NumberPicker; +import android.widget.TimePicker; + +import androidx.annotation.Nullable; +import androidx.fragment.app.Fragment; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * Fragment that demos rotary interactions directly manipulating the state of UI widgets such as a + * {@link android.widget.SeekBar}, {@link android.widget.DatePicker}, and + * {@link android.widget.RadialTimePickerView}, and {@link DirectManipulationView} in an + * application window. + */ +public class RotaryDirectManipulationWidgets extends Fragment { + + // TODO(agathaman): refactor a common class that takes in a fragment xml id and inflates it, to + // share between this and RotaryCards. + + private final DirectManipulationState mDirectManipulationMode = new DirectManipulationState(); + + @Override + public View onCreateView(LayoutInflater inflater, @Nullable ViewGroup container, + @Nullable Bundle savedInstanceState) { + View view = inflater.inflate(R.layout.rotary_direct_manipulation, container, false); + + DirectManipulationView dmv = view.findViewById(R.id.direct_manipulation_view); + registerDirectManipulationHandler(dmv, + new DirectManipulationHandler.Builder(mDirectManipulationMode) + .setNudgeHandler(new DirectManipulationView.NudgeHandler()) + .setRotationHandler(new DirectManipulationView.RotationHandler()) + .build()); + + + TimePicker spinnerTimePicker = view.findViewById(R.id.spinner_time_picker); + registerDirectManipulationHandler(spinnerTimePicker, + new DirectManipulationHandler.Builder(mDirectManipulationMode) + .setNudgeHandler(new TimePickerNudgeHandler()) + .build()); + + DirectManipulationHandler numberPickerListener = + new DirectManipulationHandler.Builder(mDirectManipulationMode) + .setNudgeHandler(new NumberPickerNudgeHandler()) + .setRotationHandler((v, motionEvent) -> { + float scroll = motionEvent.getAxisValue(MotionEvent.AXIS_SCROLL); + View focusedView = v.findFocus(); + if (focusedView instanceof NumberPicker) { + NumberPicker numberPicker = (NumberPicker) focusedView; + numberPicker.setValue(numberPicker.getValue() + Math.round(scroll)); + return true; + } + return false; + }) + .build(); + + List<NumberPicker> numberPickers = new ArrayList<>(); + getNumberPickerDescendants(numberPickers, spinnerTimePicker); + for (int i = 0; i < numberPickers.size(); i++) { + registerDirectManipulationHandler(numberPickers.get(i), numberPickerListener); + } + + registerDirectManipulationHandler(view.findViewById(R.id.clock_time_picker), + new DirectManipulationHandler.Builder( + mDirectManipulationMode) + // TODO(pardis): fix the behavior here. It does not nudge as expected. + .setNudgeHandler(new TimePickerNudgeHandler()) + .setRotationHandler((v, motionEvent) -> { + // TODO(pardis): fix the behavior here. It does not scroll as intended. + float scroll = motionEvent.getAxisValue(MotionEvent.AXIS_SCROLL); + View focusedView = v.findFocus(); + scrollView(focusedView, scroll); + return true; + }) + .build()); + + registerDirectManipulationHandler( + view.findViewById(R.id.seek_bar), + new DirectManipulationHandler.Builder(mDirectManipulationMode) + .setRotationHandler(new DelegateToA11yScrollRotationHandler()) + .build()); + + registerDirectManipulationHandler( + view.findViewById(R.id.radial_time_picker), + new DirectManipulationHandler.Builder(mDirectManipulationMode) + .setRotationHandler(new DelegateToA11yScrollRotationHandler()) + .build()); + + return view; + } + + @Override + public void onPause() { + if (mDirectManipulationMode.isActive()) { + // To ensure that the user doesn't get stuck in direct manipulation mode, disable direct + // manipulation mode when the fragment is not interactive (e.g., a dialog shows up). + mDirectManipulationMode.disable(); + } + super.onPause(); + } + + /** + * Register the given {@link DirectManipulationHandler} as both the + * {@link View.OnKeyListener} and {@link View.OnGenericMotionListener} for the given + * {@link View}. + * <p> + * Handles a {@link Nullable} {@link View} so that it can be used directly with the output of + * methods such as {@code findViewById}. + */ + private void registerDirectManipulationHandler(@Nullable View view, + DirectManipulationHandler handler) { + if (view == null) { + return; + } + view.setOnKeyListener(handler); + view.setOnGenericMotionListener(handler); + } + + /** + * A {@link View.OnGenericMotionListener} implementation that delegates handling the + * {@link MotionEvent} to the {@link AccessibilityNodeInfo#ACTION_SCROLL_FORWARD} + * or {@link AccessibilityNodeInfo#ACTION_SCROLL_BACKWARD} depending on the sign of the + * {@link MotionEvent#AXIS_SCROLL} value. + */ + private static class DelegateToA11yScrollRotationHandler + implements View.OnGenericMotionListener { + + @Override + public boolean onGenericMotion(View v, MotionEvent event) { + scrollView(v, event.getAxisValue(MotionEvent.AXIS_SCROLL)); + return true; + } + } + + /** + * A shortcut to "scrolling" a given {@link View} by delegating to A11y actions. Most useful + * in scenarios that we do not have API access to the descendants of a {@link ViewGroup} but + * also handy for other cases so we don't have to re-implement the behaviors if we already know + * that suitable A11y actions exist and are implemented for the relevant views. + */ + private static void scrollView(View view, float scroll) { + for (int i = 0; i < Math.round(Math.abs(scroll)); i++) { + view.performAccessibilityAction( + scroll > 0 + ? AccessibilityNodeInfo.ACTION_SCROLL_FORWARD + : AccessibilityNodeInfo.ACTION_SCROLL_BACKWARD, + /* arguments= */ null); + } + } + + /** + * A {@link View.OnKeyListener} for handling Direct Manipulation rotary nudge behavior + * for a {@link NumberPicker}. + * + * <p> + * This handler expects that it is being used in Direct Manipulation mode, i.e. as a directional + * delegate through a {@link DirectManipulationHandler} which can invoke it at the + * appropriate times. + * <p> + * Only handles the following {@link KeyEvent}s and in the specified way below: + * <ul> + * <li>{@link KeyEvent#KEYCODE_DPAD_UP} - explicitly disabled + * <li>{@link KeyEvent#KEYCODE_DPAD_DOWN} - explicitly disabled + * <li>{@link KeyEvent#KEYCODE_DPAD_LEFT} - nudges left + * <li>{@link KeyEvent#KEYCODE_DPAD_RIGHT} - nudges right + * </ul> + * <p> + * This handler only allows nudging left and right to other {@link View} objects within the same + * {@link TimePicker}. + */ + private static class NumberPickerNudgeHandler implements View.OnKeyListener { + + private static final Map<Integer, Integer> KEYCODE_TO_DIRECTION_MAP; + + static { + Map<Integer, Integer> map = new HashMap<>(); + map.put(KeyEvent.KEYCODE_DPAD_UP, View.FOCUS_UP); + map.put(KeyEvent.KEYCODE_DPAD_DOWN, View.FOCUS_DOWN); + map.put(KeyEvent.KEYCODE_DPAD_LEFT, View.FOCUS_LEFT); + map.put(KeyEvent.KEYCODE_DPAD_RIGHT, View.FOCUS_RIGHT); + KEYCODE_TO_DIRECTION_MAP = Collections.unmodifiableMap(map); + } + + @Override + public boolean onKey(View v, int keyCode, KeyEvent event) { + boolean isActionUp = event.getAction() == KeyEvent.ACTION_UP; + switch (keyCode) { + case KeyEvent.KEYCODE_DPAD_UP: + case KeyEvent.KEYCODE_DPAD_DOWN: + // Disable by consuming the event and not doing anything. + return true; + case KeyEvent.KEYCODE_DPAD_LEFT: + case KeyEvent.KEYCODE_DPAD_RIGHT: + if (isActionUp) { + int direction = KEYCODE_TO_DIRECTION_MAP.get(keyCode); + View nextView = v.focusSearch(direction); + if (areInTheSameTimePicker(v, nextView)) { + nextView.requestFocus(direction); + } + } + return true; + default: + return false; + } + } + + private static boolean areInTheSameTimePicker(@Nullable View view1, @Nullable View view2) { + if (view1 == null || view2 == null) { + return false; + } + TimePicker view1Ancestor = getTimePickerAncestor(view1); + TimePicker view2Ancestor = getTimePickerAncestor(view2); + return view1Ancestor == view2Ancestor; + } + + /* + * A generic version of this may come in handy as a library. Any {@link ViewGroup} view that + * supports Direct Manipulation mode will need something like this to ensure nudge actions + * don't result in navigating outside the parent {link ViewGroup} that is in Direct + * Manipulation mode. + */ + @Nullable + private static TimePicker getTimePickerAncestor(@Nullable View view) { + if (view instanceof TimePicker) { + return (TimePicker) view; + } + ViewParent viewParent = view.getParent(); + if (viewParent instanceof View) { + return getTimePickerAncestor((View) viewParent); + } + return null; + } + } + + /** + * A {@link View.OnKeyListener} for handling Direct Manipulation rotary nudge behavior + * for a {@link TimePicker}. + * <p> + * This handler expects that it is being used in Direct Manipulation mode, i.e. as a + * directional delegate through a {@link DirectManipulationHandler} which can invoke it at the + * appropriate times. + * <p> + * Only handles the following {@link KeyEvent}s and in the specified way below: + * <ul> + * <li>{@link KeyEvent#KEYCODE_DPAD_UP} - explicitly disabled + * <li>{@link KeyEvent#KEYCODE_DPAD_DOWN} - explicitly disabled + * <li>{@link KeyEvent#KEYCODE_DPAD_LEFT} - passes focus to a descendant view + * <li>{@link KeyEvent#KEYCODE_DPAD_RIGHT} - passes focus to a descendant view + * </ul> + * <p> + * When passing focus to a descendant, looks for all {@link NumberPicker} views and passes + * focus to the first one found. + * <p> + * This handler expects that any descendant {@link NumberPicker} objects have registered + * their own Direct Manipulation handlers via a {@link DirectManipulationHandler}. + */ + private static class TimePickerNudgeHandler + implements View.OnKeyListener { + + @Override + public boolean onKey(View view, int keyCode, KeyEvent keyEvent) { + if (!(view instanceof TimePicker)) { + return false; + } + boolean isActionUp = keyEvent.getAction() == KeyEvent.ACTION_UP; + switch (keyCode) { + case KeyEvent.KEYCODE_DPAD_UP: + case KeyEvent.KEYCODE_DPAD_DOWN: + // TODO(pardis): if intending to reuse this for both time pickers, + // then need to make sure it can distinguish between the two. For clock + // we may need up and down. + // Disable by consuming the event and not doing anything. + return true; + case KeyEvent.KEYCODE_DPAD_LEFT: + case KeyEvent.KEYCODE_DPAD_RIGHT: + if (isActionUp) { + TimePicker timePicker = (TimePicker) view; + List<NumberPicker> numberPickers = new ArrayList<>(); + getNumberPickerDescendants(numberPickers, timePicker); + if (numberPickers.isEmpty()) { + return false; + } + timePicker.setDescendantFocusability(ViewGroup.FOCUS_AFTER_DESCENDANTS); + numberPickers.get(0).requestFocus(); + } + return true; + default: + return false; + } + } + + } + + /* + * We don't have API access to the inner {@link View}s of a {@link TimePicker}. We do know based + * on {@code frameworks/base/core/res/res/layout/time_picker_legacy_material.xml} that a + * {@link TimePicker} that is in spinner mode will be using {@link NumberPicker}s internally, + * and that's what we rely on here. + */ + private static void getNumberPickerDescendants(List<NumberPicker> numberPickers, ViewGroup v) { + for (int i = 0; i < v.getChildCount(); i++) { + View child = v.getChildAt(i); + if (child instanceof NumberPicker) { + numberPickers.add((NumberPicker) child); + } else if (child instanceof ViewGroup) { + getNumberPickerDescendants(numberPickers, (ViewGroup) child); + } + } + } +} diff --git a/RotaryPlayground/src/com/android/car/rotaryplayground/RotaryGrid.java b/RotaryPlayground/src/com/android/car/rotaryplayground/RotaryGrid.java new file mode 100644 index 0000000..ceb0673 --- /dev/null +++ b/RotaryPlayground/src/com/android/car/rotaryplayground/RotaryGrid.java @@ -0,0 +1,59 @@ +/* + * Copyright (C) 2020 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.car.rotaryplayground; + +import android.os.Bundle; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import androidx.annotation.Nullable; +import androidx.fragment.app.Fragment; +import androidx.recyclerview.widget.GridLayoutManager; +import androidx.recyclerview.widget.RecyclerView; + +/** + * Fragment with a RecyclerView Grid to demo and test z-pattern rotating navigation and + * vertical scroll. + */ +public class RotaryGrid extends Fragment { + + private static final int NUMBER_OF_COLUMNS = 3; + + private static final String[] DATA = { + "1", "2", "3", "4", "5", "6", "7", "8", "9", "10", + "11", "12", "13", "14", "15", "16", "17", "18", "19", "20", + "21", "22", "23", "24", "25", "26", "27", "28", "29", "30", + "31", "32", "33", "34", "35", "36", "37", "38", "39", "40", + "41", "42", "43", "44", "45", "46", "47", "48", "49", "50"}; + + private RotaryGridAdapter mGridAdapter; + + @Override + public View onCreateView(LayoutInflater inflater, @Nullable ViewGroup container, + @Nullable Bundle savedInstanceState) { + View view = inflater.inflate(R.layout.rotary_grid, container, false); + populateGridItems(view); + return view; + } + + private void populateGridItems(View view) { + RecyclerView gridView = view.findViewById(R.id.rotary_grid_view); + gridView.setLayoutManager(new GridLayoutManager(getActivity(), NUMBER_OF_COLUMNS)); + mGridAdapter = new RotaryGridAdapter(getActivity(), DATA); + gridView.setAdapter(mGridAdapter); + } +} diff --git a/RotaryPlayground/src/com/android/car/rotaryplayground/RotaryGridAdapter.java b/RotaryPlayground/src/com/android/car/rotaryplayground/RotaryGridAdapter.java new file mode 100644 index 0000000..8c5492c --- /dev/null +++ b/RotaryPlayground/src/com/android/car/rotaryplayground/RotaryGridAdapter.java @@ -0,0 +1,74 @@ +/* + * Copyright (C) 2020 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.car.rotaryplayground; + +import android.content.Context; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.TextView; + +import androidx.recyclerview.widget.RecyclerView; + +import java.util.Collections; +import java.util.List; + +/** + * The adapter that populates the example rotary grid view with strings. + */ +final class RotaryGridAdapter extends RecyclerView.Adapter<RotaryGridAdapter.ItemViewHolder> { + + private final String[] mData; + private final Context mContext; + private final LayoutInflater mInflater; + + RotaryGridAdapter(Context context, String[] data) { + this.mContext = context; + this.mInflater = LayoutInflater.from(context); + this.mData = data; + } + + @Override + public ItemViewHolder onCreateViewHolder(ViewGroup parent, int viewType) { + View view = mInflater.inflate( + R.layout.rotary_grid_item, parent, /* attachToRoot= */ false); + return new ItemViewHolder(view); + } + + @Override + public void onBindViewHolder(ItemViewHolder holder, int position) { + holder.setText(mData[position]); + } + + @Override + public int getItemCount() { + return mData.length; + } + + /** The viewholder class that contains the grid item data. */ + static class ItemViewHolder extends RecyclerView.ViewHolder { + private TextView mItemText; + + ItemViewHolder(View view) { + super(view); + mItemText = itemView.findViewById(R.id.rotary_grid_item_text); + } + + void setText(String text) { + this.mItemText.setText(text); + } + } +}
\ No newline at end of file diff --git a/RotaryPlayground/src/com/android/car/rotaryplayground/RotaryMenu.java b/RotaryPlayground/src/com/android/car/rotaryplayground/RotaryMenu.java new file mode 100644 index 0000000..c87fe1b --- /dev/null +++ b/RotaryPlayground/src/com/android/car/rotaryplayground/RotaryMenu.java @@ -0,0 +1,152 @@ +/* + * Copyright (C) 2020 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.car.rotaryplayground; + +import android.os.Bundle; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.Button; + +import androidx.annotation.Nullable; +import androidx.fragment.app.Fragment; + +/** + * Fragment for the menu. + * + * On focus of a menu item, the associated fragment will start in the R.id.rotary_content container. + */ +public class RotaryMenu extends Fragment { + + private Fragment mRotaryCards = null; + private Fragment mRotaryGrid = null; + private Fragment mDirectManipulation = null; + private Fragment mSysUiDirectManipulation = null; + private Fragment mNotificationFragment = null; + private Fragment mScrollFragment = null; + + private Button mCardButton; + private Button mGridButton; + private Button mDirectManipulationButton; + private Button mSysUiDirectManipulationButton; + private Button mNotificationButton; + private Button mScrollButton; + + @Override + public View onCreateView(LayoutInflater inflater, @Nullable ViewGroup container, + @Nullable Bundle savedInstanceState) { + View view = inflater.inflate(R.layout.rotary_menu, container, false); + + mCardButton = view.findViewById(R.id.cards); + mCardButton.setOnFocusChangeListener((v, hasFocus) -> showRotaryCards(hasFocus)); + mCardButton.setOnClickListener(v -> showRotaryCards(/* hasFocus= */ true)); + + mGridButton = view.findViewById(R.id.grid); + mGridButton.setOnFocusChangeListener((v, hasFocus) -> showGridExample(hasFocus)); + mGridButton.setOnClickListener(v -> showGridExample(/* hasFocus= */ true)); + + mDirectManipulationButton = view.findViewById(R.id.direct_manipulation); + mDirectManipulationButton.setOnFocusChangeListener( + (v, hasFocus) -> showDirectManipulationExamples(hasFocus)); + mDirectManipulationButton.setOnClickListener( + (v -> showDirectManipulationExamples(/* hasFocus= */ true))); + + mSysUiDirectManipulationButton = view.findViewById(R.id.sys_ui_direct_manipulation); + mSysUiDirectManipulationButton.setOnFocusChangeListener( + (v, hasFocus) -> showSysUiDirectManipulationExamples(hasFocus)); + mSysUiDirectManipulationButton.setOnClickListener( + (v -> showSysUiDirectManipulationExamples(/* hasFocus= */ true))); + + mNotificationButton = view.findViewById(R.id.notification); + mNotificationButton.setOnFocusChangeListener( + (v, hasFocus) -> showNotificationExample(hasFocus)); + mNotificationButton.setOnClickListener(v -> showNotificationExample(/* hasFocus= */ true)); + + mScrollButton = view.findViewById(R.id.scroll); + mScrollButton.setOnFocusChangeListener((v, hasFocus) -> showScrollFragment(hasFocus)); + mScrollButton.setOnClickListener(v -> showScrollFragment(/* hasFocus= */ true)); + + return view; + } + + private void showRotaryCards(boolean hasFocus) { + if (!hasFocus) { + return; // Do nothing if no focus. + } + if (mRotaryCards == null) { + mRotaryCards = new RotaryCards(); + } + showFragment(mRotaryCards); + } + + private void showGridExample(boolean hasFocus) { + if (!hasFocus) { + return; // do nothing if no focus. + } + if (mRotaryGrid == null) { + mRotaryGrid = new RotaryGrid(); + } + showFragment(mRotaryGrid); + } + + // TODO(agathaman): refactor this and the showRotaryCards above into a + // showFragment(Fragment fragment, boolean hasFocus); method. + private void showDirectManipulationExamples(boolean hasFocus) { + if (!hasFocus) { + return; // Do nothing if no focus. + } + if (mDirectManipulation == null) { + mDirectManipulation = new RotaryDirectManipulationWidgets(); + } + showFragment(mDirectManipulation); + } + + private void showSysUiDirectManipulationExamples(boolean hasFocus) { + if (!hasFocus) { + return; // Do nothing if no focus. + } + if (mSysUiDirectManipulation == null) { + mSysUiDirectManipulation = new RotarySysUiDirectManipulationWidgets(); + } + showFragment(mSysUiDirectManipulation); + } + + private void showNotificationExample(boolean hasFocus) { + if (!hasFocus) { + return; // do nothing if no focus. + } + if (mNotificationFragment == null) { + mNotificationFragment = new HeadsUpNotificationFragment(); + } + showFragment(mNotificationFragment); + } + + private void showScrollFragment(boolean hasFocus) { + if (!hasFocus) { + return; // Do nothing if no focus. + } + if (mScrollFragment == null) { + mScrollFragment = new ScrollFragment(); + } + showFragment(mScrollFragment); + } + + private void showFragment(Fragment fragment) { + getFragmentManager().beginTransaction() + .replace(R.id.rotary_content, fragment) + .commit(); + } +} diff --git a/RotaryPlayground/src/com/android/car/rotaryplayground/RotarySysUiDirectManipulationWidgets.java b/RotaryPlayground/src/com/android/car/rotaryplayground/RotarySysUiDirectManipulationWidgets.java new file mode 100644 index 0000000..85fce89 --- /dev/null +++ b/RotaryPlayground/src/com/android/car/rotaryplayground/RotarySysUiDirectManipulationWidgets.java @@ -0,0 +1,95 @@ +/* + * Copyright (C) 2020 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.car.rotaryplayground; + +import android.graphics.Color; +import android.graphics.drawable.Drawable; +import android.os.Bundle; +import android.util.Log; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; + +import androidx.annotation.Nullable; +import androidx.fragment.app.Fragment; + +import com.android.car.ui.utils.DirectManipulationHelper; + +public class RotarySysUiDirectManipulationWidgets extends Fragment { + + @Override + public View onCreateView(LayoutInflater inflater, @Nullable ViewGroup container, + @Nullable Bundle savedInstanceState) { + View view = inflater.inflate(R.layout.rotary_sys_ui_direct_manipulation, container, false); + + View directManipulationSupportedSeekBar = view.findViewById( + R.id.direct_manipulation_supported_seek_bar); + DirectManipulationHelper.setSupportsDirectManipulation( + directManipulationSupportedSeekBar, true); + + View directManipulationUnsupportedSeekBar = view.findViewById( + R.id.direct_manipulation_unsupported_seek_bar); + directManipulationUnsupportedSeekBar.setOnClickListener( + new ChangeBackgroundColorOnClickListener()); + + View directManipulationSupportedRadialTimePickerView = view.findViewById( + R.id.direct_manipulation_supported_radial_time_picker); + DirectManipulationHelper.setSupportsDirectManipulation( + directManipulationSupportedRadialTimePickerView, true); + + View directManipulationUnsupportedRadialTimePickerView = view.findViewById( + R.id.direct_manipulation_unsupported_radial_time_picker); + directManipulationUnsupportedRadialTimePickerView.setOnClickListener( + new ChangeBackgroundColorOnClickListener()); + + return view; + } + + /** + * An {@link android.view.View.OnClickListener} object that changes the background of the + * view it is registered on to {@link Color#BLUE} and back to whatever it was before on + * subsequent calls. This is just to demo doing something visually obvious in the UI. + */ + private static class ChangeBackgroundColorOnClickListener implements View.OnClickListener { + + /** Just some random color to switch the background to for demo purposes. */ + private static final int ALTERNATIVE_BACKGROUND_COLOR = Color.BLUE; + + /** + * The purpose of this boolean is to change color every other time {@link #onClick} is + * called. + */ + private boolean mFlipFlop = true; + + @Override + public void onClick(View v) { + Log.d(L.TAG, + "Handling onClick for " + v + " and going " + (mFlipFlop ? "flip" : "flop")); + if (mFlipFlop) { + Log.d(L.TAG, "Background about to be overwritten: " + v.getBackground()); + v.setTag(R.id.saved_background_tag, v.getBackground()); + v.setBackgroundColor(ALTERNATIVE_BACKGROUND_COLOR); + } else { + Object savedBackground = v.getTag(R.id.saved_background_tag); + Log.d(L.TAG, "Restoring background: " + savedBackground); + v.setBackground((Drawable) savedBackground); + } + // Flip mode bit. + mFlipFlop = !mFlipFlop; + } + } +}
\ No newline at end of file diff --git a/RotaryPlayground/src/com/android/car/rotaryplayground/ScrollFragment.java b/RotaryPlayground/src/com/android/car/rotaryplayground/ScrollFragment.java new file mode 100644 index 0000000..49e341d --- /dev/null +++ b/RotaryPlayground/src/com/android/car/rotaryplayground/ScrollFragment.java @@ -0,0 +1,170 @@ +/* + * Copyright (C) 2020 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.car.rotaryplayground; + +import android.content.Context; +import android.os.Bundle; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.TextView; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.fragment.app.Fragment; +import androidx.recyclerview.widget.LinearLayoutManager; +import androidx.recyclerview.widget.RecyclerView; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +/** Fragment to demo scrolling with CarRecyclerView. */ +public class ScrollFragment extends Fragment { + + // Item types + private static final int TYPE_BUTTONS = 1; + private static final int TYPE_TEXT = 2; + + private View mScrollView; + + @Override + public View onCreateView(LayoutInflater inflater, @Nullable ViewGroup container, + @Nullable Bundle savedInstanceState) { + mScrollView = inflater.inflate(R.layout.rotary_scroll, container, false); + initView(); + return mScrollView; + } + + private void initView() { + List<ScrollListItem> items = new ArrayList<>(); + + // List of height for items to be added to the demo. + // 0 indicates focusable buttons inflated by R.layout.rotary_scroll.button. + // Values > 0 indiciates non-focusable texts inflated by R.layout.rotary_scroll_text. + final int[] itemHeights = getResources().getIntArray(R.array.scroll_item_heights); + + for (int height : itemHeights) { + if (height < 0) { + continue; + } + ScrollListItem item = (height == 0) + ? ScrollListItem.createButtonsItem() + : ScrollListItem.createTextItemWithHeight(height); + items.add(item); + } + + // Set adapter + Context context = getContext(); + ScrollListItemAdapter adapter = new ScrollListItemAdapter(context, items); + RecyclerView view = (RecyclerView) mScrollView.findViewById(R.id.rotary_scroll_view); + view.setAdapter(adapter); + } + + /** The adapter used by RotaryScroll to render ScrollListItems. */ + private static class ScrollListItemAdapter extends + RecyclerView.Adapter<RecyclerView.ViewHolder> { + + final Context mContext; + final List<ScrollListItem> mItems; + + ScrollListItemAdapter(Context context, List<ScrollListItem> items) { + this.mContext = context; + this.mItems = items; + } + + @NonNull + @Override + public RecyclerView.ViewHolder onCreateViewHolder( + @NonNull ViewGroup viewGroup, int viewType) { + View view; + switch (viewType) { + case TYPE_BUTTONS: + view = LayoutInflater.from(mContext).inflate( + R.layout.rotary_scroll_button, viewGroup, false); + return new RecyclerView.ViewHolder(view) {}; + + case TYPE_TEXT: + view = LayoutInflater.from(mContext).inflate( + R.layout.rotary_scroll_text, viewGroup, false); + return new ScrollTextHolder(view); + + default: + throw new IllegalArgumentException("Unexpected viewType: " + viewType); + } + } + + @Override + public int getItemViewType(int position) { + return mItems.get(position).getType(); + } + + @Override + public void onBindViewHolder(@NonNull RecyclerView.ViewHolder viewHolder, int position) { + if (getItemViewType(position) == TYPE_TEXT) { + ((ScrollTextHolder) viewHolder).setHeight(mItems.get(position).getHeight()); + } + } + + @Override + public int getItemCount() { + return mItems.size(); + } + } + + /** A ViewHolder for non-focusable ScrollListItems */ + private static class ScrollTextHolder extends RecyclerView.ViewHolder { + + @NonNull + final TextView mScrollTextView; + + ScrollTextHolder(@NonNull View itemView) { + super(itemView); + mScrollTextView = itemView.findViewById(R.id.scroll_text_view); + } + + void setHeight(int height) { + mScrollTextView.setHeight(height); + } + } + + private static class ScrollListItem { + + final int mHeight; + final int mType; + + static ScrollListItem createTextItemWithHeight(int height) { + return new ScrollListItem(TYPE_TEXT, height); + } + static ScrollListItem createButtonsItem() { + return new ScrollListItem(TYPE_BUTTONS, 0); + } + + ScrollListItem(int type, int height) { + this.mType = type; + this.mHeight = height; + } + + int getHeight() { + return mHeight; + } + + int getType() { + return mType; + } + } +} diff --git a/TestMediaApp/Android.bp b/TestMediaApp/Android.bp index f2f6d59..a2e80e2 100644 --- a/TestMediaApp/Android.bp +++ b/TestMediaApp/Android.bp @@ -23,15 +23,14 @@ android_app { resource_dirs: ["res"], - platform_apis: true, + sdk_version: "system_current", certificate: "platform", - // car_car is ok here because this is meant to simulate a third party media app // Do NOT add dependencies preventing the app from being unbundled (compiled with gradle in Studio). static_libs: [ - "androidx.car_car", "androidx.appcompat_appcompat", + "androidx-constraintlayout_constraintlayout", "androidx.preference_preference", "androidx.legacy_legacy-support-v4", ], diff --git a/TestMediaApp/AndroidManifest.xml b/TestMediaApp/AndroidManifest.xml index 59069c4..4f806c6 100644 --- a/TestMediaApp/AndroidManifest.xml +++ b/TestMediaApp/AndroidManifest.xml @@ -20,7 +20,8 @@ <uses-feature android:name="android.hardware.type.automotive" android:required="true"/> - + <uses-permission android:name="android.permission.ACCESS_FINE_LOCATION"/> + <uses-permission android:name="android.permission.FOREGROUND_SERVICE"/> <application android:allowBackup="true" android:label="@string/app_name" @@ -42,6 +43,13 @@ </intent-filter> </service> + <service android:name=".TmaForegroundService" + android:icon="@drawable/ic_app_icon" + android:exported="false" + android:foregroundServiceType="location" + android:label="@string/app_name"> + </service> + <service android:name=".TmaBrowser2" android:icon="@mipmap/ic_launcher" android:exported="true" diff --git a/TestMediaApp/assets/media_items/advanced.json b/TestMediaApp/assets/media_items/advanced.json index 45a18ef..9266a1c 100644 --- a/TestMediaApp/assets/media_items/advanced.json +++ b/TestMediaApp/assets/media_items/advanced.json @@ -54,6 +54,16 @@ "DISPLAY_TITLE": "Exceptions" }, "INCLUDE":"media_items/exceptions.json" + }, + { + "FLAGS": "playable", + "METADATA": { + "MEDIA_ID": "location", + "DISPLAY_TITLE": "Location", + "DURATION": 10000, + "ART_URI": "drawable/ic_location" + }, + "CUSTOM_ACTIONS": ["REQUEST_LOCATION"] } ] }
\ No newline at end of file diff --git a/TestMediaApp/assets/media_items/album_art/art_nodes.json b/TestMediaApp/assets/media_items/album_art/art_nodes.json index 692809f..abfc869 100644 --- a/TestMediaApp/assets/media_items/album_art/art_nodes.json +++ b/TestMediaApp/assets/media_items/album_art/art_nodes.json @@ -61,6 +61,16 @@ "DISPLAY_TITLE": "Nature files" }, "INCLUDE":"media_items/album_art/nature/art_nature_files.json" + }, + { + "FLAGS": "browsable", + "PLAYABLE_HINT": "GRID", + "METADATA": { + "MEDIA_ID": "album_art/art_nodes nature self updating", + "DISPLAY_TITLE": "Nature self updating" + }, + "SELF_UPDATE_MS": "2000", + "INCLUDE":"media_items/album_art/nature/art_nature_512.json" } ] }
\ No newline at end of file diff --git a/TestMediaApp/res/drawable/ic_location.xml b/TestMediaApp/res/drawable/ic_location.xml new file mode 100644 index 0000000..1017cbc --- /dev/null +++ b/TestMediaApp/res/drawable/ic_location.xml @@ -0,0 +1,9 @@ +<vector xmlns:android="http://schemas.android.com/apk/res/android" + android:width="24dp" + android:height="24dp" + android:viewportWidth="24.0" + android:viewportHeight="24.0"> + <path + android:fillColor="#FFFFFFFF" + android:pathData="M12,2C8.13,2 5,5.13 5,9c0,5.25 7,13 7,13s7,-7.75 7,-13c0,-3.87 -3.13,-7 -7,-7zM12,11.5c-1.38,0 -2.5,-1.12 -2.5,-2.5s1.12,-2.5 2.5,-2.5 2.5,1.12 2.5,2.5 -1.12,2.5 -2.5,2.5z"/> +</vector> diff --git a/TestMediaApp/res/values/strings.xml b/TestMediaApp/res/values/strings.xml index ac621be..1ffd39c 100644 --- a/TestMediaApp/res/values/strings.xml +++ b/TestMediaApp/res/values/strings.xml @@ -31,4 +31,5 @@ <string name="heart_less_less" translatable="false">Heart--</string> + <string name="location" translatable="false">Location</string> </resources> diff --git a/TestMediaApp/src/com/android/car/media/testmediaapp/TmaBrowser.java b/TestMediaApp/src/com/android/car/media/testmediaapp/TmaBrowser.java index 7a62137..3c3eeb6 100644 --- a/TestMediaApp/src/com/android/car/media/testmediaapp/TmaBrowser.java +++ b/TestMediaApp/src/com/android/car/media/testmediaapp/TmaBrowser.java @@ -204,11 +204,24 @@ public class TmaBrowser extends MediaBrowserServiceCompat { addSearchResults(node, pat.matcher(""), hits, MAX_SEARCH_DEPTH); result.sendResult(hits); } else { - List<MediaItem> items = new ArrayList<>(node.mChildren.size()); - for (TmaMediaItem child : node.mChildren) { - items.add(child.toMediaItem()); + List<TmaMediaItem> children = node.getChildren(); + int childrenCount = children.size(); + List<MediaItem> items = new ArrayList<>(childrenCount); + if (childrenCount <= 0) { + result.sendResult(items); + } else { + int selfUpdateDelay = node.getSelfUpdateDelay(); + int toShow = (selfUpdateDelay > 0) ? 1 + node.mRevealCounter : childrenCount; + for (int childIndex = 0 ; childIndex < toShow; childIndex++) { + items.add(children.get(childIndex).toMediaItem()); + } + result.sendResult(items); + + if (selfUpdateDelay > 0) { + mHandler.postDelayed(new UpdateNodeTask(parentId), selfUpdateDelay); + node.mRevealCounter = (node.mRevealCounter + 1) % (childrenCount); + } } - result.sendResult(items); } }; if (delay == TmaReplyDelay.NONE) { @@ -225,7 +238,7 @@ public class TmaBrowser extends MediaBrowserServiceCompat { return; } - for (TmaMediaItem child : node.mChildren) { + for (TmaMediaItem child : node.getChildren()) { MediaItem item = child.toMediaItem(); CharSequence title = item.getDescription().getTitle(); if (title != null) { @@ -240,4 +253,18 @@ public class TmaBrowser extends MediaBrowserServiceCompat { addSearchResults(child, matcher, hits, currentDepth - 1); } } + + private class UpdateNodeTask implements Runnable { + + private final String mNodeId; + + UpdateNodeTask(@NonNull String nodeId) { + mNodeId = nodeId; + } + + @Override + public void run() { + notifyChildrenChanged(mNodeId); + } + } } diff --git a/TestMediaApp/src/com/android/car/media/testmediaapp/TmaForegroundService.java b/TestMediaApp/src/com/android/car/media/testmediaapp/TmaForegroundService.java new file mode 100644 index 0000000..6241455 --- /dev/null +++ b/TestMediaApp/src/com/android/car/media/testmediaapp/TmaForegroundService.java @@ -0,0 +1,144 @@ +/* + * Copyright (C) 2020 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.car.media.testmediaapp; + +import android.app.Notification; +import android.app.NotificationChannel; +import android.app.NotificationManager; +import android.app.PendingIntent; +import android.app.Service; +import android.content.Intent; +import android.location.Location; +import android.location.LocationListener; +import android.location.LocationManager; +import android.os.Bundle; +import android.os.IBinder; +import android.widget.Toast; + +import androidx.annotation.Nullable; +import androidx.core.app.NotificationCompat; + +import com.android.car.media.testmediaapp.prefs.TmaPrefsActivity; + +/** + * Service used to test and demonstrate the access to "foreground" permissions. In particular, this + * implementation deals with location access from a headless service. This service is initiated + * using {@link Service#startService(Intent)} from the browse service as a respond to a custom + * playback command. Subsequent start commands make the service toggle between running and stopping. + * + * In real applications, this service would be handling background playback, maybe using location + * and other sensors to automatically select songs. + */ +public class TmaForegroundService extends Service { + public static final String CHANNEL_ID = "ForegroundServiceChannel"; + private LocationManager mLocationManager; + + @Override + public void onCreate() { + super.onCreate(); + } + + @Override + public int onStartCommand(Intent intent, int flags, int startId) { + if (doWork()) { + createNotificationChannel(); + Intent notificationIntent = new Intent(this, TmaPrefsActivity.class); + PendingIntent pendingIntent = PendingIntent.getActivity(this, + 0, notificationIntent, 0); + Notification notification = new NotificationCompat.Builder(this, CHANNEL_ID) + .setContentTitle("Foreground Service") + .setSmallIcon(R.drawable.ic_app_icon) + .setContentIntent(pendingIntent) + .build(); + startForeground(1, notification); + } else { + getMainExecutor().execute(this::stopSelf); + } + + return START_NOT_STICKY; + } + + @Override + public void onDestroy() { + if (mLocationManager != null) { + mLocationManager.removeUpdates(mLocationListener); + toast("Location is off"); + } + super.onDestroy(); + } + + @Nullable + @Override + public IBinder onBind(Intent intent) { + return null; + } + + private void createNotificationChannel() { + NotificationChannel serviceChannel = new NotificationChannel( + CHANNEL_ID, + "Foreground Service Channel", + NotificationManager.IMPORTANCE_DEFAULT + ); + NotificationManager manager = getSystemService(NotificationManager.class); + manager.createNotificationChannel(serviceChannel); + } + + private boolean doWork() { + if (mLocationManager != null) { + return false; + } + mLocationManager = (LocationManager) getSystemService(LOCATION_SERVICE); + try { + mLocationManager.requestLocationUpdates(LocationManager.GPS_PROVIDER, 2000, + 0, mLocationListener); + toast("Location is on"); + } catch (Throwable e) { + toast("Unable to get location: " + e.getMessage()); + } + return true; + } + + /** + * We use toasts here as it is the only way for a headless service to show something on the + * screen. Real application shouldn't be using toasts from service. + */ + private void toast(String message) { + Toast.makeText(this, message, Toast.LENGTH_SHORT).show(); + } + + private final LocationListener mLocationListener = new LocationListener() { + @Override + public void onLocationChanged(Location location) { + toast("Location provider: " + location.getLatitude() + ":" + location.getLongitude()); + } + + @Override + public void onStatusChanged(String provider, int status, Bundle extras) { + toast("Location provider: " + provider + " status changed to: " + status); + } + + @Override + public void onProviderEnabled(String provider) { + toast("Location provider enabled: " + provider); + } + + @Override + public void onProviderDisabled(String provider) { + toast("Location provider disabled: " + provider); + } + }; +} diff --git a/TestMediaApp/src/com/android/car/media/testmediaapp/TmaLibrary.java b/TestMediaApp/src/com/android/car/media/testmediaapp/TmaLibrary.java index 327a2b6..5bd74ad 100644 --- a/TestMediaApp/src/com/android/car/media/testmediaapp/TmaLibrary.java +++ b/TestMediaApp/src/com/android/car/media/testmediaapp/TmaLibrary.java @@ -66,8 +66,9 @@ class TmaLibrary { TmaMediaItem getMediaItemById(String mediaId) { TmaMediaItem result = mMediaItemsByMediaId.get(mediaId); // Processing includes only on request allows recursive structures :-) - if (result != null && !TextUtils.isEmpty(result.mInclude)) { - result = result.append(loadAssetFile(result.mInclude).mChildren); + if (result != null && !TextUtils.isEmpty(result.mInclude) + && result.getChildren().isEmpty()) { + result.setChildren(loadAssetFile(result.mInclude).getChildren()); } return result; } @@ -89,7 +90,7 @@ class TmaLibrary { private void cacheMediaItem(TmaMediaItem item) { String key = item.getMediaId(); if (mMediaItemsByMediaId.putIfAbsent(key, item) == null) { - for (TmaMediaItem child : item.mChildren) { + for (TmaMediaItem child : item.getChildren()) { cacheMediaItem(child); } } else { diff --git a/TestMediaApp/src/com/android/car/media/testmediaapp/TmaMediaItem.java b/TestMediaApp/src/com/android/car/media/testmediaapp/TmaMediaItem.java index af1b2e3..e2cb533 100644 --- a/TestMediaApp/src/com/android/car/media/testmediaapp/TmaMediaItem.java +++ b/TestMediaApp/src/com/android/car/media/testmediaapp/TmaMediaItem.java @@ -55,7 +55,9 @@ public class TmaMediaItem { HEART_PLUS_PLUS(CUSTOM_ACTION_PREFIX + "heart_plus_plus", R.string.heart_plus_plus, R.drawable.ic_heart_plus_plus), HEART_LESS_LESS(CUSTOM_ACTION_PREFIX + "heart_less_less", R.string.heart_less_less, - R.drawable.ic_heart_less_less); + R.drawable.ic_heart_less_less), + REQUEST_LOCATION(CUSTOM_ACTION_PREFIX + "location", R.string.location, + R.drawable.ic_location); final String mId; final int mNameId; @@ -66,16 +68,17 @@ public class TmaMediaItem { mNameId = name; mIcon = icon; } - } private final int mFlags; private final MediaMetadataCompat mMediaMetadata; private final ContentStyle mPlayableStyle; private final ContentStyle mBrowsableStyle; + private final int mSelfUpdateMs; - /** Read only list. */ - final List<TmaMediaItem> mChildren; + + /** Internally modifiable list (for includes). */ + private final List<TmaMediaItem> mChildren; /** Read only list. */ private final List<TmaMediaItem> mPlayableChildren; /** Read only list. */ @@ -87,18 +90,20 @@ public class TmaMediaItem { private @Nullable TmaMediaItem mParent; int mHearts; + int mRevealCounter; public TmaMediaItem(int flags, ContentStyle playableStyle, ContentStyle browsableStyle, - MediaMetadataCompat metadata, List<TmaCustomAction> customActions, - List<TmaMediaEvent> mediaEvents, + MediaMetadataCompat metadata, int selfUpdateMs, + List<TmaCustomAction> customActions, List<TmaMediaEvent> mediaEvents, List<TmaMediaItem> children, String include) { mFlags = flags; mPlayableStyle = playableStyle; mBrowsableStyle = browsableStyle; mMediaMetadata = metadata; + mSelfUpdateMs = selfUpdateMs; mCustomActions = Collections.unmodifiableList(customActions); - mChildren = Collections.unmodifiableList(children); + mChildren = children; mMediaEvents = Collections.unmodifiableList(mediaEvents); mInclude = include; List<TmaMediaItem> playableChildren = new ArrayList<>(children.size()); @@ -115,6 +120,14 @@ public class TmaMediaItem { mParent = parent; } + int getSelfUpdateDelay() { + return mSelfUpdateMs; + } + + List<TmaMediaItem> getChildren() { + return Collections.unmodifiableList(mChildren); + } + @Nullable TmaMediaItem getParent() { return mParent; @@ -155,12 +168,9 @@ public class TmaMediaItem { return result; } - TmaMediaItem append(List<TmaMediaItem> children) { - List<TmaMediaItem> allChildren = new ArrayList<>(mChildren.size() + children.size()); - allChildren.addAll(mChildren); - allChildren.addAll(children); - return new TmaMediaItem(mFlags, mPlayableStyle, mBrowsableStyle, mMediaMetadata, - mCustomActions, mMediaEvents, allChildren, null); + void setChildren(List<TmaMediaItem> children) { + mChildren.clear(); + mChildren.addAll(children); } void updateSessionMetadata(MediaSessionCompat session) { diff --git a/TestMediaApp/src/com/android/car/media/testmediaapp/TmaPlayer.java b/TestMediaApp/src/com/android/car/media/testmediaapp/TmaPlayer.java index d8fab6c..65cc787 100644 --- a/TestMediaApp/src/com/android/car/media/testmediaapp/TmaPlayer.java +++ b/TestMediaApp/src/com/android/car/media/testmediaapp/TmaPlayer.java @@ -30,6 +30,7 @@ import static android.support.v4.media.session.PlaybackStateCompat.ERROR_CODE_AP import static android.support.v4.media.session.PlaybackStateCompat.STATE_ERROR; import androidx.annotation.Nullable; + import android.app.PendingIntent; import android.content.Context; import android.content.Intent; @@ -77,7 +78,6 @@ public class TmaPlayer extends MediaSessionCompat.Callback { private TmaMediaItem mActiveItem; private int mNextEventIndex = -1; - TmaPlayer(Context context, TmaLibrary library, AudioManager audioManager, Handler handler, MediaSessionCompat session) { mContext = context; @@ -248,6 +248,8 @@ public class TmaPlayer extends MediaSessionCompat.Callback { } else if (TmaCustomAction.HEART_LESS_LESS.mId.equals(action)) { mActiveItem.mHearts--; toast("" + mActiveItem.mHearts); + } else if (TmaCustomAction.REQUEST_LOCATION.mId.equals(action)) { + mContext.startService(new Intent(mContext, TmaForegroundService.class)); } } } diff --git a/TestMediaApp/src/com/android/car/media/testmediaapp/loader/TmaMediaItemReader.java b/TestMediaApp/src/com/android/car/media/testmediaapp/loader/TmaMediaItemReader.java index 2d4b845..e62055d 100644 --- a/TestMediaApp/src/com/android/car/media/testmediaapp/loader/TmaMediaItemReader.java +++ b/TestMediaApp/src/com/android/car/media/testmediaapp/loader/TmaMediaItemReader.java @@ -24,6 +24,7 @@ import static com.android.car.media.testmediaapp.loader.TmaLoaderUtils.enumNames import static com.android.car.media.testmediaapp.loader.TmaLoaderUtils.getArray; import static com.android.car.media.testmediaapp.loader.TmaLoaderUtils.getEnum; import static com.android.car.media.testmediaapp.loader.TmaLoaderUtils.getEnumArray; +import static com.android.car.media.testmediaapp.loader.TmaLoaderUtils.getInt; import static com.android.car.media.testmediaapp.loader.TmaLoaderUtils.getString; import android.util.Log; @@ -55,6 +56,7 @@ class TmaMediaItemReader { PLAYABLE_HINT, BROWSABLE_HINT, METADATA, + SELF_UPDATE_MS, CHILDREN, INCLUDE, CUSTOM_ACTIONS, @@ -114,6 +116,7 @@ class TmaMediaItemReader { getEnum(json, Keys.PLAYABLE_HINT, mContentStyles, ContentStyle.NONE), getEnum(json, Keys.BROWSABLE_HINT, mContentStyles, ContentStyle.NONE), mMediaMetadataReader.fromJson(json.getJSONObject(Keys.METADATA.name())), + getInt(json, Keys.SELF_UPDATE_MS), getEnumArray(json, Keys.CUSTOM_ACTIONS, mCustomActions), mediaEvents, mediaItems, getString(json, Keys.INCLUDE)); } catch (JSONException e) { diff --git a/TestMediaApp/src/com/android/car/media/testmediaapp/prefs/TmaPrefsFragment.java b/TestMediaApp/src/com/android/car/media/testmediaapp/prefs/TmaPrefsFragment.java index 066cc9c..d3e681e 100644 --- a/TestMediaApp/src/com/android/car/media/testmediaapp/prefs/TmaPrefsFragment.java +++ b/TestMediaApp/src/com/android/car/media/testmediaapp/prefs/TmaPrefsFragment.java @@ -16,8 +16,12 @@ package com.android.car.media.testmediaapp.prefs; +import android.Manifest; +import android.app.Activity; import android.content.Context; +import android.content.pm.PackageManager; import android.os.Bundle; +import android.widget.Toast; import androidx.preference.DropDownPreference; import androidx.preference.Preference; @@ -30,6 +34,8 @@ import com.android.car.media.testmediaapp.prefs.TmaEnumPrefs.TmaLoginEventOrder; import com.android.car.media.testmediaapp.prefs.TmaEnumPrefs.TmaReplyDelay; import com.android.car.media.testmediaapp.prefs.TmaPrefs.PrefEntry; +import java.util.function.Consumer; + public class TmaPrefsFragment extends PreferenceFragmentCompat { @Override @@ -49,6 +55,8 @@ public class TmaPrefsFragment extends PreferenceFragmentCompat { prefs.mAssetReplyDelay, TmaReplyDelay.values())); screen.addPreference(createEnumPref(context, "Login event order", prefs.mLoginEventOrder, TmaLoginEventOrder.values())); + screen.addPreference(createClickPref(context, "Request location perm", + this::requestPermissions)); setPreferenceScreen(screen); } @@ -72,4 +80,25 @@ public class TmaPrefsFragment extends PreferenceFragmentCompat { prefWidget.setEntryValues(entryValues); return prefWidget; } + + private Preference createClickPref(Context context, String title, Consumer<Context> runnable) { + Preference prefWidget = new Preference(context); + prefWidget.setTitle(title); + prefWidget.setOnPreferenceClickListener(pref -> { + runnable.accept(context); + return true; + }); + return prefWidget; + } + + private void requestPermissions(Context context) { + if (context.checkSelfPermission(Manifest.permission.ACCESS_FINE_LOCATION) + == PackageManager.PERMISSION_GRANTED) { + Toast.makeText(context, "Location permission already granted", Toast.LENGTH_SHORT) + .show(); + } else { + ((Activity) context).requestPermissions( + new String[] {Manifest.permission.ACCESS_FINE_LOCATION}, 1); + } + } } @@ -22,6 +22,17 @@ To see TestMediaApp in Android Auto Projected: 6. Exit and re-open Android Auto 7. TestMediaApp should now be visible (click headphones icon in phone app to see app picker) +### RotaryPlayground + +RotaryPlayground is a test and reference application for the AAOS Rotary framework to use with an external rotary input device. + +To buid and install RotaryPlayground into an AAOS device: + +``` +$ make RotaryPlayground +$ adb install -r -g out/target/[path]/system/app/RotaryPlayground/RotaryPlayground.apk +``` + ## Contributing ### Workstation setup |
