Demo app for multi-window multi-device mode
Add an app that has two windows where you can draw. It showcases the new
feature of drawing into two windows with two different devices (touch
and stylus) at the same time.
Previously, this feature was not available on the platform.
Bug: 211379801
Test: APP=MultiDeviceInput; m $APP && adb install $ANDROID_PRODUCT_OUT/system/app/$APP/$APP.apk
Change-Id: I68875e3bba08a9e5ad5c917866f132e91b7c032e
diff --git a/tests/MotionPrediction/Android.bp b/tests/MotionPrediction/Android.bp
index 6cda8f0..b4a4359 100644
--- a/tests/MotionPrediction/Android.bp
+++ b/tests/MotionPrediction/Android.bp
@@ -26,5 +26,8 @@
android_app {
name: "MotionPrediction",
srcs: ["**/*.kt"],
+ kotlincflags: [
+ "-Werror",
+ ],
sdk_version: "current",
}
diff --git a/tests/MultiDeviceInput/Android.bp b/tests/MultiDeviceInput/Android.bp
new file mode 100644
index 0000000..3c80873
--- /dev/null
+++ b/tests/MultiDeviceInput/Android.bp
@@ -0,0 +1,33 @@
+//
+// Copyright 2023 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 {
+ // See: http://go/android-license-faq
+ // A large-scale-change added 'default_applicable_licenses' to import
+ // all of the 'license_kinds' from "frameworks_base_license"
+ // to get the below license kinds:
+ // SPDX-license-identifier-Apache-2.0
+ default_applicable_licenses: ["frameworks_base_license"],
+}
+
+android_app {
+ name: "MultiDeviceInput",
+ srcs: ["**/*.kt"],
+ kotlincflags: [
+ "-Werror",
+ ],
+ sdk_version: "current",
+}
diff --git a/tests/MultiDeviceInput/AndroidManifest.xml b/tests/MultiDeviceInput/AndroidManifest.xml
new file mode 100644
index 0000000..ed8cadb
--- /dev/null
+++ b/tests/MultiDeviceInput/AndroidManifest.xml
@@ -0,0 +1,34 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- Copyright 2023 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="test.multideviceinput">
+
+ <application android:allowBackup="false"
+ android:icon="@mipmap/ic_launcher"
+ android:label="@string/app_name"
+ android:supportsRtl="true"
+ android:theme="@style/AppTheme">
+ <activity android:name=".MainActivity"
+ android:exported="true">
+ <intent-filter>
+ <action android:name="android.intent.action.MAIN"/>
+ <category android:name="android.intent.category.LAUNCHER"/>
+ </intent-filter>
+ </activity>
+ </application>
+
+</manifest>
diff --git a/tests/MultiDeviceInput/OWNERS b/tests/MultiDeviceInput/OWNERS
new file mode 100644
index 0000000..c88bfe9
--- /dev/null
+++ b/tests/MultiDeviceInput/OWNERS
@@ -0,0 +1 @@
+include platform/frameworks/base:/INPUT_OWNERS
diff --git a/tests/MultiDeviceInput/README.md b/tests/MultiDeviceInput/README.md
new file mode 100644
index 0000000..5fcdeda
--- /dev/null
+++ b/tests/MultiDeviceInput/README.md
@@ -0,0 +1,19 @@
+# MultiDeviceInput test app #
+
+This demo app is for manual testing of the multi-device input feature.
+It creates two windows - one on the left and one on the right. You can use different input devices
+in these windows.
+
+## Installation ##
+Install this using:
+```
+APP=MultiDeviceInput; m $APP && adb install $ANDROID_PRODUCT_OUT/system/app/$APP/$APP.apk
+```
+
+## Features ##
+
+* Touch in one window, use stylus in another window, at the same time
+* Visualize hovering stylus
+* Pinch zoom in one window to affect the line thickness in another window
+* Check whether stylus rejects touch in the same window
+* (in the future) Check stylus and touch operation in the same window
diff --git a/tests/MultiDeviceInput/res/layout/activity_main.xml b/tests/MultiDeviceInput/res/layout/activity_main.xml
new file mode 100644
index 0000000..a6a6f891
--- /dev/null
+++ b/tests/MultiDeviceInput/res/layout/activity_main.xml
@@ -0,0 +1,27 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- Copyright 2023 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:tools="http://schemas.android.com/tools"
+ android:layout_width="match_parent"
+ android:layout_height="match_parent"
+ android:orientation="vertical"
+ android:paddingBottom="@dimen/activity_vertical_margin"
+ android:paddingLeft="@dimen/activity_horizontal_margin"
+ android:paddingRight="@dimen/activity_horizontal_margin"
+ android:paddingTop="@dimen/activity_vertical_margin"
+ tools:context="test.multideviceinput.MainActivity">
+
+</LinearLayout>
diff --git a/tests/MultiDeviceInput/res/mipmap-hdpi/ic_launcher.png b/tests/MultiDeviceInput/res/mipmap-hdpi/ic_launcher.png
new file mode 100644
index 0000000..cde69bc
--- /dev/null
+++ b/tests/MultiDeviceInput/res/mipmap-hdpi/ic_launcher.png
Binary files differ
diff --git a/tests/MultiDeviceInput/res/mipmap-mdpi/ic_launcher.png b/tests/MultiDeviceInput/res/mipmap-mdpi/ic_launcher.png
new file mode 100644
index 0000000..c133a0c
--- /dev/null
+++ b/tests/MultiDeviceInput/res/mipmap-mdpi/ic_launcher.png
Binary files differ
diff --git a/tests/MultiDeviceInput/res/mipmap-xhdpi/ic_launcher.png b/tests/MultiDeviceInput/res/mipmap-xhdpi/ic_launcher.png
new file mode 100644
index 0000000..bfa42f0
--- /dev/null
+++ b/tests/MultiDeviceInput/res/mipmap-xhdpi/ic_launcher.png
Binary files differ
diff --git a/tests/MultiDeviceInput/res/mipmap-xxhdpi/ic_launcher.png b/tests/MultiDeviceInput/res/mipmap-xxhdpi/ic_launcher.png
new file mode 100644
index 0000000..324e72c
--- /dev/null
+++ b/tests/MultiDeviceInput/res/mipmap-xxhdpi/ic_launcher.png
Binary files differ
diff --git a/tests/MultiDeviceInput/res/mipmap-xxxhdpi/ic_launcher.png b/tests/MultiDeviceInput/res/mipmap-xxxhdpi/ic_launcher.png
new file mode 100644
index 0000000..aee44e1
--- /dev/null
+++ b/tests/MultiDeviceInput/res/mipmap-xxxhdpi/ic_launcher.png
Binary files differ
diff --git a/tests/MultiDeviceInput/res/values-w820dp/dimens.xml b/tests/MultiDeviceInput/res/values-w820dp/dimens.xml
new file mode 100644
index 0000000..b14a560
--- /dev/null
+++ b/tests/MultiDeviceInput/res/values-w820dp/dimens.xml
@@ -0,0 +1,20 @@
+<!-- Copyright 2023 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>
+ <!-- Example customization of dimensions originally defined in res/values/dimens.xml
+ (such as screen margins) for screens with more than 820dp of available width. This
+ would include 7" and 10" devices in landscape (~960dp and ~1280dp respectively). -->
+ <dimen name="activity_horizontal_margin">64dp</dimen>
+</resources>
diff --git a/tests/MultiDeviceInput/res/values/colors.xml b/tests/MultiDeviceInput/res/values/colors.xml
new file mode 100644
index 0000000..c37df9f
--- /dev/null
+++ b/tests/MultiDeviceInput/res/values/colors.xml
@@ -0,0 +1,20 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- Copyright 2023 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="colorPrimary">#3F51B5</color>
+ <color name="colorPrimaryDark">#303F9F</color>
+ <color name="colorAccent">#FF4081</color>
+</resources>
diff --git a/tests/MultiDeviceInput/res/values/dimens.xml b/tests/MultiDeviceInput/res/values/dimens.xml
new file mode 100644
index 0000000..bdb8ede
--- /dev/null
+++ b/tests/MultiDeviceInput/res/values/dimens.xml
@@ -0,0 +1,19 @@
+<!-- Copyright 2023 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>
+ <!-- Default screen margins, per the Android Design guidelines. -->
+ <dimen name="activity_horizontal_margin">16dp</dimen>
+ <dimen name="activity_vertical_margin">16dp</dimen>
+</resources>
diff --git a/tests/MultiDeviceInput/res/values/strings.xml b/tests/MultiDeviceInput/res/values/strings.xml
new file mode 100644
index 0000000..3827c34
--- /dev/null
+++ b/tests/MultiDeviceInput/res/values/strings.xml
@@ -0,0 +1,17 @@
+<!-- Copyright 2023 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">Simultaneous touch and stylus</string>
+</resources>
diff --git a/tests/MultiDeviceInput/res/values/styles.xml b/tests/MultiDeviceInput/res/values/styles.xml
new file mode 100644
index 0000000..a563e7e
--- /dev/null
+++ b/tests/MultiDeviceInput/res/values/styles.xml
@@ -0,0 +1,23 @@
+<!-- Copyright 2023 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>
+ <!-- Base application theme. -->
+ <style name="AppTheme" parent="@android:style/Theme.Material.Light.DarkActionBar">
+ <!-- Customize your theme here. -->
+ <item name="android:colorPrimary">@color/colorPrimary</item>
+ <item name="android:colorPrimaryDark">@color/colorPrimaryDark</item>
+ <item name="android:colorAccent">@color/colorAccent</item>
+ </style>
+</resources>
diff --git a/tests/MultiDeviceInput/src/test/multideviceinput/DrawingView.kt b/tests/MultiDeviceInput/src/test/multideviceinput/DrawingView.kt
new file mode 100644
index 0000000..b5bd9ca
--- /dev/null
+++ b/tests/MultiDeviceInput/src/test/multideviceinput/DrawingView.kt
@@ -0,0 +1,183 @@
+/*
+ * Copyright 2023 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 test.multideviceinput
+
+import android.content.Context
+import android.graphics.Canvas
+import android.graphics.Color
+import android.graphics.Paint
+import android.util.AttributeSet
+import android.view.InputDevice.SOURCE_STYLUS
+import android.view.MotionEvent
+import android.view.MotionEvent.ACTION_DOWN
+import android.view.MotionEvent.ACTION_HOVER_EXIT
+import android.view.MotionEvent.ACTION_UP
+import android.view.ScaleGestureDetector
+import android.view.View
+
+import java.util.Vector
+
+private fun drawLine(canvas: Canvas, from: MotionEvent, to: MotionEvent, paint: Paint) {
+ // Correct implementation here would require us to build a set of pointers and then iterate
+ // through them. Instead, we are taking a few shortcuts and ignore some of the events, which
+ // causes occasional gaps in the drawings.
+ if (from.pointerCount != to.pointerCount) {
+ return
+ }
+ // Now, 'from' is guaranteed to have as many pointers as the 'to' event. It doesn't
+ // necessarily mean they are the same pointers, though.
+ for (p in 0..<from.pointerCount) {
+ val x0 = from.getX(p)
+ val y0 = from.getY(p)
+ if (to.getPointerId(p) == from.getPointerId(p)) {
+ // This only works when the i-th pointer in "to" is the same pointer
+ // as the i-th pointer in "from"`. It's not guaranteed by the input APIs,
+ // but it works in practice.
+ val x1 = to.getX(p)
+ val y1 = to.getY(p)
+ // Ignoring historical data here for simplicity
+ canvas.drawLine(x0, y0, x1, y1, paint)
+ }
+ }
+}
+
+private fun drawCircle(canvas: Canvas, event: MotionEvent, paint: Paint, radius: Float) {
+ val x = event.getX()
+ val y = event.getY()
+ canvas.drawCircle(x, y, radius, paint)
+}
+
+/**
+ * Draw the current stroke
+ */
+class DrawingView : View {
+ private val TAG = "DrawingView"
+
+ private var myState: SharedScaledPointerSize? = null
+ private var otherState: SharedScaledPointerSize? = null
+
+ constructor(
+ context: Context,
+ myState: SharedScaledPointerSize,
+ otherState: SharedScaledPointerSize
+ ) : super(context) {
+ this.myState = myState
+ this.otherState = otherState
+ init()
+ }
+
+ constructor(context: Context, attrs: AttributeSet) : super(context, attrs) {
+ init()
+ }
+
+ val touchEvents = mutableMapOf<Int, Vector<Pair<MotionEvent, Paint>>>()
+ val hoverEvents = mutableMapOf<Int, MotionEvent>()
+
+ val scaleGestureListener = object : ScaleGestureDetector.SimpleOnScaleGestureListener() {
+
+ override fun onScaleBegin(scaleGestureDetector: ScaleGestureDetector): Boolean {
+ return true
+ }
+
+ override fun onScale(scaleGestureDetector: ScaleGestureDetector): Boolean {
+ val scaleFactor = scaleGestureDetector.scaleFactor
+ when (otherState?.state) {
+ PointerState.DOWN -> {
+ otherState?.lineSize = (otherState?.lineSize ?: 5f) * scaleFactor
+ }
+ PointerState.HOVER -> {
+ otherState?.circleSize = (otherState?.circleSize ?: 20f) * scaleFactor
+ }
+ else -> {}
+ }
+ return true
+ }
+ }
+ private val scaleGestureDetector = ScaleGestureDetector(context, scaleGestureListener, null)
+
+ private var touchPaint = Paint()
+ private var stylusPaint = Paint()
+
+ private fun init() {
+ touchPaint.color = Color.RED
+ touchPaint.setStrokeWidth(5f)
+ stylusPaint.color = Color.YELLOW
+ stylusPaint.setStrokeWidth(5f)
+
+ setOnHoverListener { _, event -> processHoverEvent(event); true }
+ }
+
+ private fun processTouchEvent(event: MotionEvent) {
+ scaleGestureDetector.onTouchEvent(event)
+ if (event.actionMasked == ACTION_DOWN) {
+ touchEvents.remove(event.deviceId)
+ myState?.state = PointerState.DOWN
+ } else if (event.actionMasked == ACTION_UP) {
+ myState?.state = PointerState.NONE
+ }
+ var vec = touchEvents.getOrPut(event.deviceId) { Vector<Pair<MotionEvent, Paint>>() }
+
+ val paint = if (event.isFromSource(SOURCE_STYLUS)) {
+ val size = myState?.lineSize ?: 5f
+ stylusPaint.setStrokeWidth(size)
+ Paint(stylusPaint)
+ } else {
+ val size = myState?.lineSize ?: 5f
+ touchPaint.setStrokeWidth(size)
+ Paint(touchPaint)
+ }
+ vec.add(Pair(MotionEvent.obtain(event), paint))
+ invalidate()
+ }
+
+ private fun processHoverEvent(event: MotionEvent) {
+ hoverEvents.remove(event.deviceId)
+ if (event.getActionMasked() != ACTION_HOVER_EXIT) {
+ hoverEvents.put(event.deviceId, MotionEvent.obtain(event))
+ myState?.state = PointerState.HOVER
+ } else {
+ myState?.state = PointerState.NONE
+ }
+ invalidate()
+ }
+
+ public override fun onTouchEvent(event: MotionEvent): Boolean {
+ processTouchEvent(event)
+ return true
+ }
+
+ public override fun onDraw(canvas: Canvas) {
+ super.onDraw(canvas)
+
+ // Draw touch and stylus MotionEvents
+ for ((_, vec) in touchEvents ) {
+ for (i in 1 until vec.size) {
+ drawLine(canvas, vec[i - 1].first, vec[i].first, vec[i].second)
+ }
+ }
+ // Draw hovers
+ for ((_, event) in hoverEvents ) {
+ if (event.isFromSource(SOURCE_STYLUS)) {
+ val size = myState?.circleSize ?: 20f
+ drawCircle(canvas, event, stylusPaint, size)
+ } else {
+ val size = myState?.circleSize ?: 20f
+ drawCircle(canvas, event, touchPaint, size)
+ }
+ }
+ }
+}
diff --git a/tests/MultiDeviceInput/src/test/multideviceinput/MainActivity.kt b/tests/MultiDeviceInput/src/test/multideviceinput/MainActivity.kt
new file mode 100644
index 0000000..9112085
--- /dev/null
+++ b/tests/MultiDeviceInput/src/test/multideviceinput/MainActivity.kt
@@ -0,0 +1,83 @@
+/*
+ * Copyright 2023 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 test.multideviceinput
+
+import android.app.Activity
+import android.graphics.Color
+import android.view.Gravity
+import android.view.View
+import android.view.ViewGroup
+import android.view.WindowInsets.Type
+import android.view.WindowManager
+
+
+enum class PointerState {
+ DOWN, // One or more pointer(s) down, lines are being drawn
+ HOVER, // Pointer is hovering
+ NONE, // Nothing is touching or hovering
+}
+
+data class SharedScaledPointerSize(
+ var lineSize: Float,
+ var circleSize: Float,
+ var state: PointerState
+)
+
+class MainActivity : Activity() {
+ val TAG = "MultiDeviceInput"
+ private val leftState = SharedScaledPointerSize(5f, 20f, PointerState.NONE)
+ private val rightState = SharedScaledPointerSize(5f, 20f, PointerState.NONE)
+ private lateinit var left: View
+ private lateinit var right: View
+
+ override fun onResume() {
+ super.onResume()
+
+ val wm = getSystemService(WindowManager::class.java)
+ val wmlp = WindowManager.LayoutParams(WindowManager.LayoutParams.TYPE_APPLICATION)
+ wmlp.flags = (wmlp.flags or
+ WindowManager.LayoutParams.FLAG_NOT_TOUCH_MODAL or
+ WindowManager.LayoutParams.FLAG_SPLIT_TOUCH)
+
+ val windowMetrics = windowManager.currentWindowMetrics
+ val insets = windowMetrics.windowInsets.getInsetsIgnoringVisibility(Type.systemBars())
+ val width = windowMetrics.bounds.width() - insets.left - insets.right
+ val height = windowMetrics.bounds.height() - insets.top - insets.bottom
+
+ wmlp.width = width * 24 / 50
+ wmlp.height = height * 35 / 50
+
+ val vglp = ViewGroup.LayoutParams(
+ ViewGroup.LayoutParams.WRAP_CONTENT,
+ ViewGroup.LayoutParams.WRAP_CONTENT
+ )
+
+ wmlp.setTitle("Left -- " + getPackageName())
+ wmlp.gravity = Gravity.CENTER_VERTICAL or Gravity.START
+ left = DrawingView(this, leftState, rightState)
+ left.setBackgroundColor(Color.LTGRAY)
+ left.setLayoutParams(vglp)
+ wm.addView(left, wmlp)
+
+ wmlp.setTitle("Right -- " + getPackageName())
+ wmlp.gravity = Gravity.CENTER_VERTICAL or Gravity.END
+ right = DrawingView(this, rightState, leftState)
+ right.setBackgroundColor(Color.LTGRAY)
+ right.setLayoutParams(vglp)
+ wm.addView(right, wmlp)
+ }
+}