Implement MotionTool Library using ViewCapture library

Bug: 255929005
Test: atest frameworks/libs/systemui/motiontoollib/tests/com/android/app/motiontool
Change-Id: Ie5a847124cfe99831e7ea8f2e9b99eacbe905760
diff --git a/motiontoollib/.gitignore b/motiontoollib/.gitignore
new file mode 100644
index 0000000..6213826
--- /dev/null
+++ b/motiontoollib/.gitignore
@@ -0,0 +1,13 @@
+*.iml
+.project
+.classpath
+.project.properties
+gen/
+bin/
+.idea/
+.gradle/
+local.properties
+gradle/
+build/
+gradlew*
+.DS_Store
diff --git a/motiontoollib/Android.bp b/motiontoollib/Android.bp
new file mode 100644
index 0000000..911be1d
--- /dev/null
+++ b/motiontoollib/Android.bp
@@ -0,0 +1,80 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package {
+    default_applicable_licenses: ["Android-Apache-2.0"],
+}
+
+java_library {
+    name: "motion_tool_proto",
+    srcs: ["src/com/android/app/motiontool/proto/*.proto"],
+    proto: {
+        type: "nano",
+        local_include_dirs:[
+            "src/com/android/app/motiontool/proto"
+        ],
+        include_dirs: [
+            "frameworks/libs/systemui/viewcapturelib/src/com/android/app/viewcapture/proto"
+        ],
+    },
+    static_libs: [
+        "libprotobuf-java-nano",
+        "view_capture_proto",
+    ],
+    java_version: "1.8",
+}
+
+android_library {
+    name: "motion_tool_lib",
+    manifest: "AndroidManifest.xml",
+    platform_apis: true,
+    min_sdk_version: "26",
+
+    static_libs: [
+        "androidx.core_core",
+        "view_capture",
+        "motion_tool_proto",
+    ],
+
+    srcs: [
+        "src/**/*.java",
+        "src/**/*.kt"
+    ],
+}
+
+android_test {
+    name: "motion_tool_lib_tests",
+    manifest: "tests/AndroidManifest.xml",
+    platform_apis: true,
+    min_sdk_version: "26",
+
+    static_libs: [
+        "androidx.core_core",
+        "view_capture",
+        "motion_tool_proto",
+        "androidx.test.ext.junit",
+        "androidx.test.rules",
+        "testables"
+    ],
+    srcs: [
+        "**/*.java",
+        "**/*.kt"
+    ],
+    libs: [
+        "android.test.runner",
+        "android.test.base",
+    ],
+    test_suites: ["device-tests"],
+}
+
diff --git a/motiontoollib/AndroidManifest.xml b/motiontoollib/AndroidManifest.xml
new file mode 100644
index 0000000..3b8a656
--- /dev/null
+++ b/motiontoollib/AndroidManifest.xml
@@ -0,0 +1,20 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+     Copyright (C) 2022 The Android Open Source Project
+
+     Licensed under the Apache License, Version 2.0 (the "License");
+     you may not use this file except in compliance with the License.
+     You may obtain a copy of the License at
+
+          http://www.apache.org/licenses/LICENSE-2.0
+
+     Unless required by applicable law or agreed to in writing, software
+     distributed under the License is distributed on an "AS IS" BASIS,
+     WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+     See the License for the specific language governing permissions and
+     limitations under the License.
+-->
+
+<manifest xmlns:android="http://schemas.android.com/apk/res/android"
+    package="com.android.app.motiontool">
+</manifest>
diff --git a/motiontoollib/TEST_MAPPING b/motiontoollib/TEST_MAPPING
new file mode 100644
index 0000000..22a9e6b
--- /dev/null
+++ b/motiontoollib/TEST_MAPPING
@@ -0,0 +1,15 @@
+{
+  "presubmit": [
+    {
+      "name": "motion_tool_lib_tests",
+      "options": [
+        {
+          "exclude-annotation": "org.junit.Ignore"
+        },
+        {
+          "exclude-annotation": "androidx.test.filters.FlakyTest"
+        }
+      ]
+    }
+  ]
+}
diff --git a/motiontoollib/build.gradle b/motiontoollib/build.gradle
new file mode 100644
index 0000000..7a75751
--- /dev/null
+++ b/motiontoollib/build.gradle
@@ -0,0 +1,64 @@
+plugins {
+    id 'sysuigradleproject.android-library-conventions'
+    id 'org.jetbrains.kotlin.android'
+    id 'com.google.protobuf'
+}
+
+final String PROTOS_DIR = "${ANDROID_TOP}/frameworks/libs/systemui/motiontoollib/src/com/android/app/motiontool/proto"
+
+android {
+    compileSdk TARGET_SDK.toInteger()
+    buildToolsVersion = BUILD_TOOLS_VERSION
+
+    defaultConfig {
+        testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
+    }
+
+    defaultConfig {
+        minSdkVersion TARGET_SDK.toInteger()
+        targetSdkVersion TARGET_SDK.toInteger()
+    }
+
+    sourceSets {
+        main {
+            java.srcDirs = ['src']
+            manifest.srcFile 'AndroidManifest.xml'
+            proto.srcDirs = ["${PROTOS_DIR}"]
+        }
+        androidTest {
+            java.srcDirs = ["tests"]
+            manifest.srcFile "tests/AndroidManifest.xml"
+        }
+    }
+
+    lintOptions {
+        abortOnError false
+    }
+}
+
+dependencies {
+    implementation "androidx.core:core:1.9.0"
+    implementation PROTOBUF_DEPENDENCY
+    api project(":ViewCaptureLib")
+    androidTestImplementation project(':SharedTestLib')
+    androidTestImplementation 'androidx.test.ext:junit:1.1.3'
+    androidTestImplementation "androidx.test:rules:1.4.0"
+
+}
+
+protobuf {
+    // Configure the protoc executable
+    protoc {
+        artifact = "com.google.protobuf:protoc:3.0.0${PROTO_ARCH_SUFFIX}"
+        generateProtoTasks {
+            all().each { task ->
+                task.builtins {
+                    remove java
+                    javanano {
+                        option "enum_style=c"
+                    }
+                }
+            }
+        }
+    }
+}
diff --git a/motiontoollib/src/com/android/app/motiontool/DdmHandleMotionTool.kt b/motiontoollib/src/com/android/app/motiontool/DdmHandleMotionTool.kt
new file mode 100644
index 0000000..f11c848
--- /dev/null
+++ b/motiontoollib/src/com/android/app/motiontool/DdmHandleMotionTool.kt
@@ -0,0 +1,180 @@
+/*
+ * Copyright (C) 2022 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.app.motiontool
+
+import android.ddm.DdmHandle
+import com.android.app.motiontool.nano.*
+import com.google.protobuf.nano.InvalidProtocolBufferNanoException
+import com.google.protobuf.nano.MessageNano
+import org.apache.harmony.dalvik.ddmc.Chunk
+import org.apache.harmony.dalvik.ddmc.ChunkHandler
+import org.apache.harmony.dalvik.ddmc.DdmServer
+
+/**
+ * This class handles the 'MOTO' type DDM requests (defined in [motion_tool.proto]).
+ *
+ * It executes some validity checks and forwards valid requests to the [MotionToolManager]. It
+ * requires a [MotionToolsRequest] as parameter and returns a [MotionToolsResponse]. Failures will
+ * return a [MotionToolsResponse] with the [error][MotionToolsResponse.error] field set instead of
+ * the respective return value.
+ *
+ * To activate this server, call [register]. This will register the DdmHandleMotionTool with the
+ * [DdmServer]. The DdmHandleMotionTool can be registered once per process. To unregister from the
+ * DdmServer, call [unregister].
+ */
+class DdmHandleMotionTool private constructor(
+    private val motionToolManager: MotionToolManager
+) : DdmHandle() {
+
+    companion object {
+        val CHUNK_MOTO = ChunkHandler.type("MOTO")
+        private const val SERVER_VERSION = 1
+
+        @Volatile
+        private var INSTANCE: DdmHandleMotionTool? = null
+
+        @Synchronized
+        fun getInstance(motionToolManager: MotionToolManager): DdmHandleMotionTool {
+            return INSTANCE ?: DdmHandleMotionTool(motionToolManager).also {
+                INSTANCE = it
+            }
+        }
+    }
+
+    fun register() {
+        DdmServer.registerHandler(CHUNK_MOTO, this)
+    }
+
+    fun unregister() {
+        DdmServer.unregisterHandler(CHUNK_MOTO)
+    }
+
+    override fun handleChunk(request: Chunk): Chunk {
+        val requestDataBuffer = wrapChunk(request)
+        val protoRequest =
+            try {
+                MotionToolsRequest.parseFrom(requestDataBuffer.array())
+            } catch (e: InvalidProtocolBufferNanoException) {
+                val parseErrorResponse =
+                    ErrorResponse().apply {
+                        code = ErrorResponse.INVALID_REQUEST
+                        message = "Invalid request format (Protobuf parse exception)"
+                    }
+                val wrappedResponse = MotionToolsResponse().apply { error = parseErrorResponse }
+                val responseData = MessageNano.toByteArray(wrappedResponse)
+                return Chunk(CHUNK_MOTO, responseData, 0, responseData.size)
+            }
+
+        val response =
+            when (protoRequest.typeCase) {
+                MotionToolsRequest.HANDSHAKE_FIELD_NUMBER ->
+                    handleHandshakeRequest(protoRequest.handshake)
+                MotionToolsRequest.BEGIN_TRACE_FIELD_NUMBER ->
+                    handleBeginTraceRequest(protoRequest.beginTrace)
+                MotionToolsRequest.POLL_TRACE_FIELD_NUMBER ->
+                    handlePollTraceRequest(protoRequest.pollTrace)
+                MotionToolsRequest.END_TRACE_FIELD_NUMBER ->
+                    handleEndTraceRequest(protoRequest.endTrace)
+                else ->
+                    MotionToolsResponse().apply {
+                        error =
+                            ErrorResponse().apply {
+                                code = ErrorResponse.INVALID_REQUEST
+                                message = "Unknown request type"
+                            }
+                    }
+            }
+
+        val responseData = MessageNano.toByteArray(response)
+        return Chunk(CHUNK_MOTO, responseData, 0, responseData.size)
+    }
+
+    private fun handleBeginTraceRequest(beginTraceRequest: BeginTraceRequest) =
+        MotionToolsResponse().apply {
+            tryCatchingMotionToolManagerExceptions {
+                beginTrace =
+                    BeginTraceResponse().apply {
+                        traceId = motionToolManager.beginTrace(beginTraceRequest.window.rootWindow)
+                    }
+            }
+        }
+
+    private fun handlePollTraceRequest(pollTraceRequest: PollTraceRequest) =
+        MotionToolsResponse().apply {
+            tryCatchingMotionToolManagerExceptions {
+                pollTrace =
+                    PollTraceResponse().apply {
+                        exportedData = motionToolManager.pollTrace(pollTraceRequest.traceId)
+                    }
+            }
+        }
+
+    private fun handleEndTraceRequest(endTraceRequest: EndTraceRequest) =
+        MotionToolsResponse().apply {
+            tryCatchingMotionToolManagerExceptions {
+                endTrace =
+                    EndTraceResponse().apply {
+                        exportedData = motionToolManager.endTrace(endTraceRequest.traceId)
+                    }
+            }
+        }
+
+    private fun handleHandshakeRequest(handshakeRequest: HandshakeRequest) =
+        MotionToolsResponse().apply {
+            handshake =
+                HandshakeResponse().apply {
+                    serverVersion = SERVER_VERSION
+                    status =
+                        if (motionToolManager.hasWindow(handshakeRequest.window)) {
+                            HandshakeResponse.OK
+                        } else {
+                            HandshakeResponse.WINDOW_NOT_FOUND
+                        }
+                }
+        }
+
+    /**
+     * Executes the [block] and catches all Exceptions thrown by [MotionToolManager]. In case of an
+     * exception being caught, the error response field of the [MotionToolsResponse] is being set
+     * with the according [ErrorResponse].
+     */
+    private fun MotionToolsResponse.tryCatchingMotionToolManagerExceptions(block: () -> Unit) {
+        try {
+            block()
+        } catch (e: UnknownTraceIdException) {
+            error = createUnknownTraceIdResponse(e.traceId)
+        } catch (e: WindowNotFoundException) {
+            error = createWindowNotFoundResponse(e.windowId)
+        }
+    }
+
+    private fun createUnknownTraceIdResponse(traceId: Int) =
+        ErrorResponse().apply {
+            this.code = ErrorResponse.UNKNOWN_TRACE_ID
+            this.message = "No running Trace found with traceId $traceId"
+        }
+
+    private fun createWindowNotFoundResponse(windowId: String) =
+        ErrorResponse().apply {
+            this.code = ErrorResponse.WINDOW_NOT_FOUND
+            this.message = "No window found with windowId $windowId"
+        }
+
+    override fun onConnected() {}
+
+    override fun onDisconnected() {}
+}
diff --git a/motiontoollib/src/com/android/app/motiontool/MotionToolManager.kt b/motiontoollib/src/com/android/app/motiontool/MotionToolManager.kt
new file mode 100644
index 0000000..392134d
--- /dev/null
+++ b/motiontoollib/src/com/android/app/motiontool/MotionToolManager.kt
@@ -0,0 +1,149 @@
+/*
+ * Copyright (C) 2022 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.app.motiontool
+
+import android.util.Log
+import android.view.View
+import android.view.WindowManagerGlobal
+import com.android.app.motiontool.nano.WindowIdentifier
+import com.android.app.viewcapture.ViewCapture
+import com.android.app.viewcapture.data.nano.ExportedData
+
+/**
+ * Singleton to manage motion tracing sessions.
+ *
+ * A motion tracing session captures motion-relevant data on a frame-by-frame basis for a given
+ * window, as long as the trace is running.
+ *
+ * To start a trace, use [beginTrace]. The returned handle must be used to terminate tracing and
+ * receive the data by calling [endTrace]. While the trace is active, data is buffered, however
+ * the buffer size is limited (@see [ViewCapture.MEMORY_SIZE]. Use [pollTrace] periodically to
+ * ensure no data is dropped. Both, [pollTrace] and [endTrace] only return data captured since the
+ * last call to either [beginTrace] or [endTrace].
+ *
+ * NOTE: a running trace will incur some performance penalty. Only keep traces running while a user
+ * requested it.
+ *
+ * @see [DdmHandleMotionTool]
+ */
+class MotionToolManager private constructor(
+    private val viewCapture: ViewCapture,
+    private val windowManagerGlobal: WindowManagerGlobal
+) {
+
+    companion object {
+        private const val TAG = "MotionToolManager"
+
+        @Volatile
+        private var INSTANCE: MotionToolManager? = null
+
+        @Synchronized
+        fun getInstance(
+            viewCapture: ViewCapture,
+            windowManagerGlobal: WindowManagerGlobal
+        ): MotionToolManager {
+            return INSTANCE ?: MotionToolManager(viewCapture, windowManagerGlobal).also {
+                INSTANCE = it
+            }
+        }
+    }
+
+    private var traceIdCounter = 0
+    private val traces = mutableMapOf<Int, TraceMetadata>()
+
+    fun hasWindow(windowId: WindowIdentifier): Boolean {
+        val rootView = getRootView(windowId.rootWindow)
+        return rootView != null
+    }
+
+    /** Starts [ViewCapture] and returns a traceId. */
+    fun beginTrace(windowId: String): Int {
+        val traceId = ++traceIdCounter
+        Log.d(TAG, "Begin Trace for id: $traceId")
+        val rootView = getRootView(windowId) ?: throw WindowNotFoundException(windowId)
+        val autoCloseable = viewCapture.startCapture(rootView, windowId)
+        traces[traceId] = TraceMetadata(windowId, 0, autoCloseable::close)
+        return traceId
+    }
+
+    /**
+     * Ends [ViewCapture] and returns the captured [ExportedData] since the [beginTrace] call or the
+     * last [pollTrace] call.
+     */
+    fun endTrace(traceId: Int): ExportedData {
+        Log.d(TAG, "End Trace for id: $traceId")
+        val traceMetadata = traces.getOrElse(traceId) { throw UnknownTraceIdException(traceId) }
+        val exportedData = pollTrace(traceId)
+        traceMetadata.stopTrace()
+        traces.remove(traceId)
+        return exportedData
+    }
+
+    /**
+     * Returns the [ExportedData] captured since the [beginTrace] call or the last [pollTrace] call.
+     * This function can only be used after [beginTrace] is called and before [endTrace] is called.
+     */
+    fun pollTrace(traceId: Int): ExportedData {
+        val traceMetadata = traces.getOrElse(traceId) { throw UnknownTraceIdException(traceId) }
+        val exportedData = getExportedDataFromViewCapture(traceMetadata)
+        traceMetadata.updateLastPolledTime(exportedData)
+        return exportedData
+    }
+
+    fun reset() {
+        for(traceMetadata in traces.values){
+            traceMetadata.stopTrace()
+        }
+        traces.clear()
+        traceIdCounter = 0
+    }
+
+    private fun getExportedDataFromViewCapture(traceMetadata: TraceMetadata): ExportedData {
+        val rootView =
+            getRootView(traceMetadata.windowId)
+                ?: throw WindowNotFoundException(traceMetadata.windowId)
+        return viewCapture
+            .getDumpTask(rootView)
+            ?.orElse(null)
+            ?.get()
+            ?.apply {
+                frameData = frameData?.filter { it.timestamp > traceMetadata.lastPolledTime }
+                    ?.toTypedArray()
+            }
+            ?: ExportedData()
+    }
+
+    private fun getRootView(windowId: String): View? {
+        return windowManagerGlobal.getRootView(windowId)
+    }
+}
+
+private data class TraceMetadata(
+    val windowId: String,
+    var lastPolledTime: Long,
+    var stopTrace: () -> Unit
+) {
+    fun updateLastPolledTime(exportedData: ExportedData?) {
+        exportedData?.frameData?.maxOfOrNull { it.timestamp }?.let { maxFrameTimestamp ->
+            lastPolledTime = maxFrameTimestamp
+        }
+    }
+}
+
+class UnknownTraceIdException(val traceId: Int) : Exception()
+
+class WindowNotFoundException(val windowId: String) : Exception()
diff --git a/motiontoollib/src/com/android/app/motiontool/proto/motion_tool.proto b/motiontoollib/src/com/android/app/motiontool/proto/motion_tool.proto
new file mode 100644
index 0000000..cd5ad2f
--- /dev/null
+++ b/motiontoollib/src/com/android/app/motiontool/proto/motion_tool.proto
@@ -0,0 +1,113 @@
+/*
+ * Copyright (C) 2022 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+syntax = "proto2";
+
+package com.android.app.motiontool;
+
+import "view_capture.proto";
+
+option java_multiple_files = true;
+
+message MotionToolsRequest {
+  oneof type {
+    HandshakeRequest handshake = 1;
+    BeginTraceRequest begin_trace = 2;
+    EndTraceRequest end_trace = 3;
+    PollTraceRequest poll_trace = 4;
+  }
+}
+
+// RPC response messages.
+//
+// Returns the result from the corresponding request.
+message MotionToolsResponse {
+  oneof type {
+    // Contains error information whenever the request failed.
+    ErrorResponse error = 1;
+
+    HandshakeResponse handshake = 2;
+    BeginTraceResponse begin_trace = 3;
+    EndTraceResponse end_trace = 4;
+    PollTraceResponse poll_trace = 5;
+  }
+}
+
+message ErrorResponse {
+  enum Code {
+    UNKNOWN = 0;
+    INVALID_REQUEST = 1;
+    UNKNOWN_TRACE_ID = 2;
+    WINDOW_NOT_FOUND = 3;
+  }
+
+  optional Code code = 1;
+  // Human readable error message.
+  optional string message = 2;
+}
+
+// Identifies the window, in which context the motion tools are executed
+message WindowIdentifier {
+  // An identifier for the root view, as accepted by
+  // WindowManagerGlobal#getRootView. This is formatted as
+  // `windowName/rootViewClassName@rootViewIdentityHashCode`,
+  // for example `NotificationShade/android.view.ViewRootImpl@bab6a53`.
+  optional string root_window = 1;
+}
+
+// Verifies the motion tools are available for the specified window.
+message HandshakeRequest {
+  optional WindowIdentifier window = 1;
+  optional int32 client_version = 2;
+}
+
+message HandshakeResponse {
+  enum Status {
+    OK = 1;
+    WINDOW_NOT_FOUND = 2;
+  }
+  optional Status status = 1;
+  optional int32 server_version = 2;
+}
+
+// Enables motion tracing for the specified window
+message BeginTraceRequest {
+  optional WindowIdentifier window = 1;
+}
+
+message BeginTraceResponse {
+  optional int32 trace_id = 1;
+}
+
+// Disabled motion tracing for the specified window
+message EndTraceRequest {
+  optional int32 trace_id = 1;
+}
+
+message EndTraceResponse {
+  optional com.android.app.viewcapture.data.ExportedData exported_data = 1;
+}
+
+// Polls collected motion trace data collected since the last PollTraceRequest (or the
+// BeginTraceRequest)
+message PollTraceRequest {
+  optional int32 trace_id = 1;
+}
+
+message PollTraceResponse {
+  optional com.android.app.viewcapture.data.ExportedData exported_data = 1;
+}
+
diff --git a/motiontoollib/tests/AndroidManifest.xml b/motiontoollib/tests/AndroidManifest.xml
new file mode 100644
index 0000000..3db8d2f
--- /dev/null
+++ b/motiontoollib/tests/AndroidManifest.xml
@@ -0,0 +1,37 @@
+<?xml version="1.0" encoding="utf-8"?><!--
+     Copyright (C) 2022 The Android Open Source Project
+
+     Licensed under the Apache License, Version 2.0 (the "License");
+     you may not use this file except in compliance with the License.
+     You may obtain a copy of the License at
+
+          http://www.apache.org/licenses/LICENSE-2.0
+
+     Unless required by applicable law or agreed to in writing, software
+     distributed under the License is distributed on an "AS IS" BASIS,
+     WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+     See the License for the specific language governing permissions and
+     limitations under the License.
+-->
+
+<manifest xmlns:android="http://schemas.android.com/apk/res/android"
+    package="com.android.app.motiontool">
+
+    <application
+        android:debuggable="true"
+        android:theme="@android:style/Theme.NoTitleBar">
+
+        <activity
+            android:name=".util.TestActivity"
+            android:exported="false" />
+
+        <uses-library android:name="android.test.runner" />
+
+    </application>
+
+    <instrumentation
+        android:name="android.testing.TestableInstrumentation"
+        android:label="Tests for MotionTool Lib"
+        android:targetPackage="com.android.app.motiontool"/>
+
+</manifest>
diff --git a/motiontoollib/tests/com/android/app/motiontool/DdmHandleMotionToolTest.kt b/motiontoollib/tests/com/android/app/motiontool/DdmHandleMotionToolTest.kt
new file mode 100644
index 0000000..c3d796d
--- /dev/null
+++ b/motiontoollib/tests/com/android/app/motiontool/DdmHandleMotionToolTest.kt
@@ -0,0 +1,214 @@
+/*
+ * Copyright (C) 2022 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.app.motiontool
+
+import android.content.Intent
+import android.testing.AndroidTestingRunner
+import android.view.View
+import android.view.WindowManagerGlobal
+import androidx.test.ext.junit.rules.ActivityScenarioRule
+import androidx.test.filters.SmallTest
+import androidx.test.platform.app.InstrumentationRegistry
+import com.android.app.motiontool.DdmHandleMotionTool.Companion.CHUNK_MOTO
+import com.android.app.motiontool.nano.BeginTraceRequest
+import com.android.app.motiontool.nano.EndTraceRequest
+import com.android.app.motiontool.nano.ErrorResponse
+import com.android.app.motiontool.nano.HandshakeRequest
+import com.android.app.motiontool.nano.HandshakeResponse
+import com.android.app.motiontool.nano.MotionToolsRequest
+import com.android.app.motiontool.nano.MotionToolsResponse
+import com.android.app.motiontool.nano.PollTraceRequest
+import com.android.app.motiontool.nano.WindowIdentifier
+import com.android.app.motiontool.util.TestActivity
+import com.android.app.viewcapture.ViewCapture
+import com.google.protobuf.nano.MessageNano
+import junit.framework.Assert
+import junit.framework.Assert.assertEquals
+import org.apache.harmony.dalvik.ddmc.Chunk
+import org.apache.harmony.dalvik.ddmc.ChunkHandler.wrapChunk
+import org.junit.After
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+
+
+@SmallTest
+@RunWith(AndroidTestingRunner::class)
+class DdmHandleMotionToolTest {
+
+    private val viewCaptureMemorySize = 100
+    private val viewCaptureInitPoolSize = 15
+    private val viewCapture =
+        ViewCapture.getInstance(false, viewCaptureMemorySize, viewCaptureInitPoolSize)
+    private val windowManagerGlobal = WindowManagerGlobal.getInstance()
+    private val motionToolManager = MotionToolManager.getInstance(viewCapture, windowManagerGlobal)
+    private val ddmHandleMotionTool = DdmHandleMotionTool.getInstance(motionToolManager)
+    private val CLIENT_VERSION = 1
+
+    private val activityIntent =
+        Intent(InstrumentationRegistry.getInstrumentation().context, TestActivity::class.java)
+
+    @get:Rule
+    val activityScenarioRule = ActivityScenarioRule<TestActivity>(activityIntent)
+
+    @Before
+    fun setup() {
+        ddmHandleMotionTool.register()
+    }
+
+    @After
+    fun cleanup() {
+        ddmHandleMotionTool.unregister()
+        motionToolManager.reset()
+    }
+
+    @Test
+    fun testHandshakeErrorWithInvalidWindowId() {
+        val handshakeResponse = performHandshakeRequest("InvalidWindowId")
+        assertEquals(HandshakeResponse.WINDOW_NOT_FOUND, handshakeResponse.handshake.status)
+    }
+
+    @Test
+    fun testHandshakeOkWithValidWindowId() {
+        val handshakeResponse = performHandshakeRequest(getActivityViewRootId())
+        assertEquals(HandshakeResponse.OK, handshakeResponse.handshake.status)
+    }
+
+    @Test
+    fun testBeginFailsWithInvalidWindowId() {
+        val errorResponse = performBeginTraceRequest("InvalidWindowId")
+        assertEquals(ErrorResponse.WINDOW_NOT_FOUND, errorResponse.error.code)
+    }
+
+    @Test
+    fun testEndTraceFailsWithoutPrecedingBeginTrace() {
+        val errorResponse = performEndTraceRequest(0)
+        assertEquals(ErrorResponse.UNKNOWN_TRACE_ID, errorResponse.error.code)
+    }
+
+    @Test
+    fun testPollTraceFailsWithoutPrecedingBeginTrace() {
+        val errorResponse = performPollTraceRequest(0)
+        assertEquals(ErrorResponse.UNKNOWN_TRACE_ID, errorResponse.error.code)
+    }
+
+    @Test
+    fun testEndTraceFailsWithInvalidTraceId() {
+        val beginTraceResponse = performBeginTraceRequest(getActivityViewRootId())
+        val endTraceResponse = performEndTraceRequest(beginTraceResponse.beginTrace.traceId + 1)
+        assertEquals(ErrorResponse.UNKNOWN_TRACE_ID, endTraceResponse.error.code)
+    }
+
+    @Test
+    fun testPollTraceFailsWithInvalidTraceId() {
+        val beginTraceResponse = performBeginTraceRequest(getActivityViewRootId())
+        val endTraceResponse = performPollTraceRequest(beginTraceResponse.beginTrace.traceId + 1)
+        assertEquals(ErrorResponse.UNKNOWN_TRACE_ID, endTraceResponse.error.code)
+    }
+
+    @Test
+    fun testMalformedRequestFails() {
+        val requestBytes = ByteArray(9)
+        val requestChunk = Chunk(CHUNK_MOTO, requestBytes, 0, requestBytes.size)
+        val responseChunk = ddmHandleMotionTool.handleChunk(requestChunk)
+        val response = MotionToolsResponse.parseFrom(wrapChunk(responseChunk).array()).error
+        assertEquals(ErrorResponse.INVALID_REQUEST, response.code)
+    }
+
+    @Test
+    fun testNoOnDrawCallReturnsEmptyTrace() {
+        activityScenarioRule.scenario.onActivity {
+            val beginTraceResponse = performBeginTraceRequest(getActivityViewRootId())
+            val endTraceResponse = performEndTraceRequest(beginTraceResponse.beginTrace.traceId)
+            Assert.assertTrue(endTraceResponse.endTrace.exportedData.frameData.isEmpty())
+        }
+    }
+
+    @Test
+    fun testOneOnDrawCallReturnsOneFrameResponse() {
+        var traceId = 0
+        activityScenarioRule.scenario.onActivity {
+            val beginTraceResponse = performBeginTraceRequest(getActivityViewRootId())
+            traceId = beginTraceResponse.beginTrace.traceId
+            val rootView = it.findViewById<View>(android.R.id.content)
+            rootView.invalidate()
+        }
+
+        // waits until main looper has no remaining tasks and is idle
+        activityScenarioRule.scenario.onActivity {
+            val pollTraceResponse = performPollTraceRequest(traceId)
+            assertEquals(1, pollTraceResponse.pollTrace.exportedData.frameData.size)
+
+            // Verify that frameData is only included once and is not returned again
+            val endTraceResponse = performEndTraceRequest(traceId)
+            assertEquals(0, endTraceResponse.endTrace.exportedData.frameData.size)
+        }
+
+    }
+
+    private fun performPollTraceRequest(requestTraceId: Int): MotionToolsResponse {
+        val pollTraceRequest = MotionToolsRequest().apply {
+            pollTrace = PollTraceRequest().apply {
+                traceId = requestTraceId
+            }
+        }
+        return performRequest(pollTraceRequest)
+    }
+
+    private fun performEndTraceRequest(requestTraceId: Int): MotionToolsResponse {
+        val endTraceRequest = MotionToolsRequest().apply {
+            endTrace = EndTraceRequest().apply {
+                traceId = requestTraceId
+            }
+        }
+        return performRequest(endTraceRequest)
+    }
+
+    private fun performBeginTraceRequest(windowId: String): MotionToolsResponse {
+        val beginTraceRequest = MotionToolsRequest().apply {
+            beginTrace = BeginTraceRequest().apply {
+                window = WindowIdentifier().apply {
+                    rootWindow = windowId
+                }
+            }
+        }
+        return performRequest(beginTraceRequest)
+    }
+
+    private fun performHandshakeRequest(windowId: String): MotionToolsResponse {
+        val handshakeRequest = MotionToolsRequest().apply {
+            handshake = HandshakeRequest().apply {
+                window = WindowIdentifier().apply {
+                    rootWindow = windowId
+                }
+                clientVersion = CLIENT_VERSION
+            }
+        }
+        return performRequest(handshakeRequest)
+    }
+
+    private fun performRequest(motionToolsRequest: MotionToolsRequest): MotionToolsResponse {
+        val requestBytes = MessageNano.toByteArray(motionToolsRequest)
+        val requestChunk = Chunk(CHUNK_MOTO, requestBytes, 0, requestBytes.size)
+        val responseChunk = ddmHandleMotionTool.handleChunk(requestChunk)
+        return MotionToolsResponse.parseFrom(wrapChunk(responseChunk).array())
+    }
+
+    private fun getActivityViewRootId() = WindowManagerGlobal.getInstance().viewRootNames.first()
+
+}
diff --git a/motiontoollib/tests/com/android/app/motiontool/MotionToolManagerTest.kt b/motiontoollib/tests/com/android/app/motiontool/MotionToolManagerTest.kt
new file mode 100644
index 0000000..0e1450a
--- /dev/null
+++ b/motiontoollib/tests/com/android/app/motiontool/MotionToolManagerTest.kt
@@ -0,0 +1,117 @@
+/*
+ * Copyright (C) 2022 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.app.motiontool
+
+import android.content.Intent
+import android.testing.AndroidTestingRunner
+import android.view.View
+import android.view.WindowManagerGlobal
+import androidx.test.ext.junit.rules.ActivityScenarioRule
+import androidx.test.filters.SmallTest
+import androidx.test.platform.app.InstrumentationRegistry
+import com.android.app.motiontool.util.TestActivity
+import com.android.app.viewcapture.ViewCapture
+import junit.framework.Assert.assertEquals
+import junit.framework.Assert.assertTrue
+import org.junit.After
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+
+
+@SmallTest
+@RunWith(AndroidTestingRunner::class)
+class MotionToolManagerTest {
+
+    private val windowManagerGlobal = WindowManagerGlobal.getInstance()
+    private val viewCaptureMemorySize = 100
+    private val viewCaptureInitPoolSize = 15
+    private val viewCapture =
+        ViewCapture.getInstance(false, viewCaptureMemorySize, viewCaptureInitPoolSize)
+    private val motionToolManager = MotionToolManager.getInstance(viewCapture, windowManagerGlobal)
+
+    private val activityIntent =
+        Intent(InstrumentationRegistry.getInstrumentation().context, TestActivity::class.java)
+
+    @get:Rule
+    val activityScenarioRule = ActivityScenarioRule<TestActivity>(activityIntent)
+
+    @After
+    fun cleanup() {
+        motionToolManager.reset()
+    }
+
+    @Test(expected = UnknownTraceIdException::class)
+    fun testEndTraceThrowsWithoutPrecedingBeginTrace() {
+        motionToolManager.endTrace(0)
+    }
+
+    @Test(expected = UnknownTraceIdException::class)
+    fun testPollTraceThrowsWithoutPrecedingBeginTrace() {
+        motionToolManager.pollTrace(0)
+    }
+
+    @Test(expected = UnknownTraceIdException::class)
+    fun testEndTraceThrowsWithInvalidTraceId() {
+        val traceId = motionToolManager.beginTrace(getActivityViewRootId())
+        motionToolManager.endTrace(traceId + 1)
+    }
+
+    @Test(expected = UnknownTraceIdException::class)
+    fun testPollTraceThrowsWithInvalidTraceId() {
+        val traceId = motionToolManager.beginTrace(getActivityViewRootId())
+        motionToolManager.pollTrace(traceId + 1)
+    }
+
+    @Test(expected = WindowNotFoundException::class)
+    fun testBeginTraceThrowsWithInvalidWindowId() {
+        motionToolManager.beginTrace("InvalidWindowId")
+    }
+
+    @Test
+    fun testNoOnDrawCallReturnsEmptyResponse() {
+        activityScenarioRule.scenario.onActivity {
+            val traceId = motionToolManager.beginTrace(getActivityViewRootId())
+            val result = motionToolManager.endTrace(traceId)
+            assertTrue(result.frameData.isEmpty())
+        }
+    }
+
+    @Test
+    fun testOneOnDrawCallReturnsOneFrameResponse() {
+        var traceId = 0
+        activityScenarioRule.scenario.onActivity {
+            traceId = motionToolManager.beginTrace(getActivityViewRootId())
+            val rootView = it.findViewById<View>(android.R.id.content)
+            rootView.invalidate()
+        }
+
+        // waits until main looper has no remaining tasks and is idle
+        activityScenarioRule.scenario.onActivity {
+            val polledExportedData = motionToolManager.pollTrace(traceId)
+            assertEquals(1, polledExportedData.frameData.size)
+
+            // Verify that frameData is only included once and is not returned again
+            val endExportedData = motionToolManager.endTrace(traceId)
+            assertEquals(0, endExportedData.frameData.size)
+        }
+
+    }
+
+    private fun getActivityViewRootId() = WindowManagerGlobal.getInstance().viewRootNames.first()
+
+}
diff --git a/motiontoollib/tests/com/android/app/motiontool/util/TestActivity.kt b/motiontoollib/tests/com/android/app/motiontool/util/TestActivity.kt
new file mode 100644
index 0000000..a9d68ab
--- /dev/null
+++ b/motiontoollib/tests/com/android/app/motiontool/util/TestActivity.kt
@@ -0,0 +1,21 @@
+/*
+ * Copyright (C) 2022 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.app.motiontool.util
+
+import android.app.Activity
+
+class TestActivity : Activity()