From a7cfbd7341723d4f4484e90a62fb430d58dd8bc8 Mon Sep 17 00:00:00 2001 From: Daniel Toyama Date: Sat, 17 Oct 2026 18:58:48 -0700 Subject: [PATCH] Restore export of AndroidEnv apps (AccessibilityForwarder, Catch). PiperOrigin-RevId: 1001642970 --- .../AccessibilityForwarder.kt | 293 ++++++++++ .../AccessibilityForwarderTest.kt | 516 ++++++++++++++++++ .../AccessibilityTreeCreator.kt | 235 ++++++++ .../AccessibilityTreeCreatorTest.kt | 85 +++ .../AndroidManifest.xml | 48 ++ .../AndroidManifest_lite.xml | 24 + .../FlagsBroadcastReceiver.kt | 60 ++ .../FlagsBroadcastReceiverTest.kt | 166 ++++++ .../accessibilityforwarder/LogFlags.kt | 32 ++ .../ParentChildNodePair.kt | 40 ++ .../UniqueIdsGenerator.kt | 29 + .../xml/accessibility_forwarder_service.xml | 22 + .../androidenv/catch/AndroidManifest.xml | 39 ++ .../com/google/androidenv/catch/BUILD.bazel | 80 +++ .../com/google/androidenv/catch/GameLogic.kt | 76 +++ .../androidenv/catch/GameLogicThread.kt | 45 ++ .../google/androidenv/catch/MainActivity.kt | 152 ++++++ .../google/androidenv/catch/RenderThread.kt | 53 ++ .../androidenv/catch/res/layout/main.xml | 27 + .../androidenv/catch/res/values/strings.xml | 19 + .../androidenv/catch/sprite/BUILD.bazel | 64 +++ .../androidenv/catch/sprite/Background.kt | 26 + .../google/androidenv/catch/sprite/Ball.kt | 108 ++++ .../androidenv/catch/sprite/LineSegment.kt | 18 + .../google/androidenv/catch/sprite/Paddle.kt | 70 +++ .../google/androidenv/catch/sprite/Point.kt | 18 + .../google/androidenv/catch/sprite/Sprite.kt | 24 + .../androidenv/catch/AndroidManifest.xml | 39 ++ .../com/google/androidenv/catch/BUILD.bazel | 87 +++ .../google/androidenv/catch/GameLogicTest.kt | 195 +++++++ .../androidenv/catch/GameLogicThreadTest.kt | 72 +++ .../androidenv/catch/MainActivityTest.kt | 85 +++ .../androidenv/catch/RenderThreadTest.kt | 136 +++++ .../androidenv/catch/sprite/BUILD.bazel | 82 +++ .../androidenv/catch/sprite/BackgroundTest.kt | 58 ++ .../androidenv/catch/sprite/BallTest.kt | 387 +++++++++++++ .../androidenv/catch/sprite/PaddleTest.kt | 184 +++++++ .../androidenv/catch/sprite/SpriteTest.kt | 55 ++ 38 files changed, 3749 insertions(+) create mode 100644 android_env/apps/java/com/google/androidenv/accessibilityforwarder/AccessibilityForwarder.kt create mode 100644 android_env/apps/java/com/google/androidenv/accessibilityforwarder/AccessibilityForwarderTest.kt create mode 100644 android_env/apps/java/com/google/androidenv/accessibilityforwarder/AccessibilityTreeCreator.kt create mode 100644 android_env/apps/java/com/google/androidenv/accessibilityforwarder/AccessibilityTreeCreatorTest.kt create mode 100644 android_env/apps/java/com/google/androidenv/accessibilityforwarder/AndroidManifest.xml create mode 100644 android_env/apps/java/com/google/androidenv/accessibilityforwarder/AndroidManifest_lite.xml create mode 100644 android_env/apps/java/com/google/androidenv/accessibilityforwarder/FlagsBroadcastReceiver.kt create mode 100644 android_env/apps/java/com/google/androidenv/accessibilityforwarder/FlagsBroadcastReceiverTest.kt create mode 100644 android_env/apps/java/com/google/androidenv/accessibilityforwarder/LogFlags.kt create mode 100644 android_env/apps/java/com/google/androidenv/accessibilityforwarder/ParentChildNodePair.kt create mode 100644 android_env/apps/java/com/google/androidenv/accessibilityforwarder/UniqueIdsGenerator.kt create mode 100644 android_env/apps/java/com/google/androidenv/accessibilityforwarder/res/xml/accessibility_forwarder_service.xml create mode 100644 android_env/apps/java/com/google/androidenv/catch/AndroidManifest.xml create mode 100644 android_env/apps/java/com/google/androidenv/catch/BUILD.bazel create mode 100644 android_env/apps/java/com/google/androidenv/catch/GameLogic.kt create mode 100644 android_env/apps/java/com/google/androidenv/catch/GameLogicThread.kt create mode 100644 android_env/apps/java/com/google/androidenv/catch/MainActivity.kt create mode 100644 android_env/apps/java/com/google/androidenv/catch/RenderThread.kt create mode 100644 android_env/apps/java/com/google/androidenv/catch/res/layout/main.xml create mode 100644 android_env/apps/java/com/google/androidenv/catch/res/values/strings.xml create mode 100644 android_env/apps/java/com/google/androidenv/catch/sprite/BUILD.bazel create mode 100644 android_env/apps/java/com/google/androidenv/catch/sprite/Background.kt create mode 100644 android_env/apps/java/com/google/androidenv/catch/sprite/Ball.kt create mode 100644 android_env/apps/java/com/google/androidenv/catch/sprite/LineSegment.kt create mode 100644 android_env/apps/java/com/google/androidenv/catch/sprite/Paddle.kt create mode 100644 android_env/apps/java/com/google/androidenv/catch/sprite/Point.kt create mode 100644 android_env/apps/java/com/google/androidenv/catch/sprite/Sprite.kt create mode 100644 android_env/apps/javatests/com/google/androidenv/catch/AndroidManifest.xml create mode 100644 android_env/apps/javatests/com/google/androidenv/catch/BUILD.bazel create mode 100644 android_env/apps/javatests/com/google/androidenv/catch/GameLogicTest.kt create mode 100644 android_env/apps/javatests/com/google/androidenv/catch/GameLogicThreadTest.kt create mode 100644 android_env/apps/javatests/com/google/androidenv/catch/MainActivityTest.kt create mode 100644 android_env/apps/javatests/com/google/androidenv/catch/RenderThreadTest.kt create mode 100644 android_env/apps/javatests/com/google/androidenv/catch/sprite/BUILD.bazel create mode 100644 android_env/apps/javatests/com/google/androidenv/catch/sprite/BackgroundTest.kt create mode 100644 android_env/apps/javatests/com/google/androidenv/catch/sprite/BallTest.kt create mode 100644 android_env/apps/javatests/com/google/androidenv/catch/sprite/PaddleTest.kt create mode 100644 android_env/apps/javatests/com/google/androidenv/catch/sprite/SpriteTest.kt diff --git a/android_env/apps/java/com/google/androidenv/accessibilityforwarder/AccessibilityForwarder.kt b/android_env/apps/java/com/google/androidenv/accessibilityforwarder/AccessibilityForwarder.kt new file mode 100644 index 00000000..a2aba7e6 --- /dev/null +++ b/android_env/apps/java/com/google/androidenv/accessibilityforwarder/AccessibilityForwarder.kt @@ -0,0 +1,293 @@ +// Copyright 2026 DeepMind Technologies Limited. +// +// 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.google.androidenv.accessibilityforwarder + +import android.accessibilityservice.AccessibilityService +import android.util.Log +import android.view.accessibility.AccessibilityEvent +import android.view.accessibility.AccessibilityNodeInfo +import android.view.accessibility.AccessibilityWindowInfo +import com.google.androidenv.accessibilityforwarder.A11yServiceGrpcKt.A11yServiceCoroutineStub +import io.grpc.ManagedChannel +import io.grpc.ManagedChannelBuilder +import io.grpc.ProxyDetector +import io.grpc.StatusException +import kotlinx.coroutines.TimeoutCancellationException +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout + +/** + * An Android service that listens to accessibility events and forwards them via gRPC. + * + * This service also logs the accessibility tree if [LogFlags.logAccessibilityTree] is set and if + * [LogFlags.grpcPort] is positive. + * + * Please see + * https://developer.android.com/reference/android/view/accessibility/AccessibilityEvent#getEventType() + * for a comprehensive list of events emitted by Android. + */ +class AccessibilityForwarder( + private val channelFactory: (host: String, port: Int) -> ManagedChannel = { host, port -> + ManagedChannelBuilder.forAddress(host, port) + .proxyDetector(ProxyDetector { _ -> null }) + .usePlaintext() + .build() + } +) : AccessibilityService() { + + init { + // Spawn long-running thread for periodically logging the tree. + Thread( + Runnable { + while (LogFlags.a11yTreePeriodMs > 0) { + try { + logAccessibilityTree() + } catch (e: ConcurrentModificationException) { + continue + } + + Thread.sleep(/* millis= */ LogFlags.a11yTreePeriodMs) + } + } + ) + .start() + } + + // grpcStub has a backing property that can be reset to null. + private var _grpcStub: A11yServiceCoroutineStub? = null + val grpcStub: A11yServiceCoroutineStub + get() { + if (_grpcStub == null) { + Log.i(TAG, "Building channel on ${LogFlags.grpcHost}:${LogFlags.grpcPort}.") + _grpcStub = A11yServiceCoroutineStub(channelFactory(LogFlags.grpcHost, LogFlags.grpcPort)) + } + return _grpcStub!! + } + + private fun resetGrpcStub() { + _grpcStub = null + } + + override fun onInterrupt() { + LogFlags.a11yTreePeriodMs = 0 // Turn off periodic tree forwarding. + } + + override fun onAccessibilityEvent(event: AccessibilityEvent?) { + if (event == null) { + Log.i(TAG, "`event` is null.") + return + } + + logExtrasForEvent(event) + val eventType = event.eventType + val eventTypeStr: String = AccessibilityEvent.eventTypeToString(eventType) + if (eventTypeStr.isNotEmpty()) { + Log.i(TAG, eventTypeStr) + } + } + + private fun logAccessibilityTree() { + if (!LogFlags.logAccessibilityTree) { + Log.i(TAG, "Not logging accessibility tree") + return + } + + val windows = getWindowsOrNull() + + if (windows == null) { + Log.i(TAG, "windows is null.") + return + } + + // Check gRPC port before actually building the forest. + if (LogFlags.grpcPort <= 0) { + Log.w(TAG, "Can't log accessibility tree because gRPC port has not been set.") + return + } + + val forest = creator.buildForest(windows) + try { + val grpcTimeoutMillis = 1000L + val response: ForestResponse = + with(grpcStub) { + Log.i(TAG, "sending (blocking) gRPC request for tree.") + runBlocking { withTimeout(grpcTimeoutMillis) { sendForest(forest) } } + } + if (response.error.isNotEmpty()) { + Log.w(TAG, "gRPC response.error: ${response.error}") + } else { + Log.i(TAG, "gRPC request for tree succeeded.") + } + } catch (e: StatusException) { + Log.w(TAG, "gRPC StatusException; are you sure networking is turned on?") + Log.i(TAG, "extra: exception ['$e']") + resetGrpcStub() + } catch (e: TimeoutCancellationException) { + Log.w(TAG, "gRPC TimeoutCancellationException; are you sure networking is turned on?") + Log.i(TAG, "extra: exception ['$e']") + resetGrpcStub() + } + } + + private fun getWindowsOrNull(): List? = + try { + windows + } catch (e: NullPointerException) { + null + } + + /** Logs extras for all event types. */ + private fun logExtrasForEvent(event: AccessibilityEvent) { + + val events: MutableMap = mutableMapOf() + + val sourceDescription = event.source?.contentDescription() + if (!sourceDescription.isNullOrEmpty()) { + events.put("source_content_description", sourceDescription) + } + + // Output the event text. + val eventText = event.text.joinToString(", ") + if (eventText.isNotEmpty()) { + events.put("event_text", eventText) + } + + // Output the source text. + val sourceText = event.source?.text?.toString() + if (!sourceText.isNullOrEmpty()) { + events.put("source_text", sourceText) + } + + val eventTypeStr: String = AccessibilityEvent.eventTypeToString(event.eventType) + if (eventTypeStr.isNotEmpty()) { + events.put("event_type", eventTypeStr) + } + + val className = event.source?.className?.toString() + if (!className.isNullOrEmpty()) { + events.put("source_class_name", className) + } + + val packageName = event.packageName?.toString() + if (!packageName.isNullOrEmpty()) { + events.put("event_package_name", packageName) + } + + // Text editing properties. + val beforeText = event.beforeText?.toString() + if (!beforeText.isNullOrEmpty()) { + events.put("before_text", beforeText) + } + + val fromIndex = event.fromIndex + if (fromIndex != -1) { + events.put("from_index", fromIndex.toString()) + } + + val toIndex = event.toIndex + if (toIndex != -1) { + events.put("to_index", toIndex.toString()) + } + + val addedCount = event.addedCount + if (addedCount != -1) { + events.put("added_count", addedCount.toString()) + } + + val removedCount = event.removedCount + if (removedCount != -1) { + events.put("removed_count", removedCount.toString()) + } + + // Text traversal properties + val movementGranularity = event.movementGranularity + if (movementGranularity != 0) { + events.put("movement_granularity", movementGranularity.toString()) + } + + val action = event.action + if (action != 0) { + events.put("action", action.toString()) + } + + // Scrolling properties. + if (eventTypeStr == "TYPE_VIEW_SCROLLED") { + events.put("scroll_delta_x", event.scrollDeltaX.toString()) + events.put("scroll_delta_y", event.scrollDeltaY.toString()) + } + + // Report viewID so we know exactly where the event came from. + val viewId = event.source?.viewIdResourceName?.toString() + if (!viewId.isNullOrEmpty()) { + events.put("view_id", viewId) + } + + // Format [events] as a Python dict. + if (events.isNotEmpty()) { + events.put("event_timestamp_ms", event.eventTime.toString(10)) + // Check if we want to use gRPC. + if (LogFlags.grpcPort > 0) { + try { + val grpcTimeoutMillis = 1000L + val request = eventRequest { this.event.putAll(events) } + val response: EventResponse = + with(grpcStub) { + Log.i(TAG, "sending (blocking) gRPC request for event.") + runBlocking { withTimeout(grpcTimeoutMillis) { sendEvent(request) } } + } + if (response.error.isNotEmpty()) { + Log.w(TAG, "gRPC response.error: ${response.error}") + } else { + Log.i(TAG, "gRPC request for event succeeded.") + } + } catch (e: StatusException) { + Log.w(TAG, "gRPC StatusException; are you sure networking is turned on?") + Log.i(TAG, "extra: exception ['$e']") + resetGrpcStub() + } catch (e: TimeoutCancellationException) { + Log.w(TAG, "gRPC TimeoutCancellationException; are you sure networking is turned on?") + Log.i(TAG, "extra: exception ['$e']") + resetGrpcStub() + } + } else { + Log.w(TAG, "Can't log accessibility event because gRPC port has not been set.") + } + } + } + + /** Recursively climbs the accessibility tree until the root, collecting descriptions. */ + private fun AccessibilityNodeInfo?.contentDescription(): String { + if (this == null) { + return "" + } + + val descriptions = mutableListOf() + var current: AccessibilityNodeInfo? = this + while (current != null) { + val description = current.contentDescription + if (description != null) { + descriptions.add(description.toString()) + } + + current = current.parent + } + return descriptions.joinToString(", ") + } + + companion object { + private const val TAG = "AndroidRLTask" + private val creator = AccessibilityTreeCreator() + } +} diff --git a/android_env/apps/java/com/google/androidenv/accessibilityforwarder/AccessibilityForwarderTest.kt b/android_env/apps/java/com/google/androidenv/accessibilityforwarder/AccessibilityForwarderTest.kt new file mode 100644 index 00000000..4ad29367 --- /dev/null +++ b/android_env/apps/java/com/google/androidenv/accessibilityforwarder/AccessibilityForwarderTest.kt @@ -0,0 +1,516 @@ +// Copyright 2026 DeepMind Technologies Limited. +// +// 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.google.androidenv.accessibilityforwarder + +import android.view.accessibility.AccessibilityEvent +import android.view.accessibility.AccessibilityNodeInfo +import android.view.accessibility.AccessibilityWindowInfo +import com.google.common.truth.Truth.assertThat +import io.grpc.Status +import io.grpc.StatusException +import io.grpc.inprocess.InProcessChannelBuilder +import io.grpc.inprocess.InProcessServerBuilder +import io.grpc.testing.GrpcCleanupRule +import org.junit.Assert.assertFalse +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestParameterInjector +import org.robolectric.Shadows.shadowOf + +@RunWith(RobolectricTestParameterInjector::class) +class AccessibilityForwarderTest { + + @get:Rule(order = 1) val cleanupRule = GrpcCleanupRule() + + class FakeAccessibilityService : A11yServiceGrpcKt.A11yServiceCoroutineImplBase() { + var sendForestChecker: (AndroidAccessibilityForest) -> String = { _ -> "" } + var sendEventChecker: (EventRequest) -> String = { _ -> "" } + + override suspend fun sendForest(request: AndroidAccessibilityForest) = forestResponse { + error = sendForestChecker(request) + } + + override suspend fun sendEvent(request: EventRequest) = eventResponse { + error = sendEventChecker(request) + } + } + + protected lateinit var forwarder: AccessibilityForwarder + protected val fakeA11yService = FakeAccessibilityService() + protected val channel by lazy { + val serverName: String = InProcessServerBuilder.generateName() + cleanupRule.register( + InProcessServerBuilder.forName(serverName) + .directExecutor() + .addService(fakeA11yService) + .build() + .start() + ) + cleanupRule.register(InProcessChannelBuilder.forName(serverName).directExecutor().build()) + } + + /** Initializes [forwarder] and [LogFlags] from the given args. */ + fun createForwarder( + logAccessibilityTree: Boolean = false, + a11yTreePeriodMs: Long = 0, + grpcHost: String = "10.0.2.2", + grpcPort: Int = 0, + a11yWindows: MutableList? = null, + ) { + LogFlags.logAccessibilityTree = logAccessibilityTree + LogFlags.a11yTreePeriodMs = a11yTreePeriodMs + LogFlags.grpcHost = grpcHost + LogFlags.grpcPort = grpcPort + forwarder = AccessibilityForwarder({ _, _ -> channel }) + if (a11yWindows == null) { + shadowOf(forwarder).setWindows(mutableListOf(AccessibilityWindowInfo.obtain())) + } else { + shadowOf(forwarder).setWindows(a11yWindows) + } + } + + @Test + fun onInterrupt_doesNotCrash() { + // Arrange. + createForwarder(logAccessibilityTree = false) + fakeA11yService.sendEventChecker = { _: EventRequest -> + assertFalse(true) // This should not be called. + "" // This should be unreachable + } + + // Act. + forwarder.onInterrupt() + + // Assert. + // See `sendEventChecker` above. + } + + @Test + fun onAccessibilityEvent_nullEventShouldBeIgnored() { + // Arrange. + createForwarder(logAccessibilityTree = false) + fakeA11yService.sendEventChecker = { _: EventRequest -> + assertFalse(true) // This should not be called. + "" // This should be unreachable + } + + // Act. + forwarder.onAccessibilityEvent(null) + + // Assert. + // See `sendEventChecker` above. + } + + @Test + fun onAccessibilityEvent_knownEventWithNoInformationShouldNotBeEmitted() { + // Arrange. + createForwarder(logAccessibilityTree = false) + var nodeInfo = AccessibilityNodeInfo() + nodeInfo.setContentDescription("") + var event = AccessibilityEvent() + shadowOf(event).setSourceNode(nodeInfo) + fakeA11yService.sendEventChecker = { _: EventRequest -> + assertFalse(true) // This should not be called. + "" // This should be unreachable + } + + // Act. + forwarder.onAccessibilityEvent(event) + + // Assert. + // See `sendEventChecker` above. + } + + @Test + fun onAccessibilityEvent_typeViewClicked_sendEventViaGrpc() { + // Arrange. + createForwarder(logAccessibilityTree = false, grpcPort = 1234) + forwarder = AccessibilityForwarder({ _, _ -> channel }) + var nodeInfo = AccessibilityNodeInfo() + nodeInfo.setContentDescription("My Content Description") + nodeInfo.setText("My Source Text") + nodeInfo.setClassName("AwesomeClass") + var event = AccessibilityEvent() + event.setEventTime(1357924680) + event.setEventType(AccessibilityEvent.TYPE_VIEW_CLICKED) + event.getText().add("Some text!") + event.setPackageName("some.loooong.package.name") + shadowOf(event).setSourceNode(nodeInfo) + fakeA11yService.sendEventChecker = { request: EventRequest -> + // Check that all fields are consistent with how they were set above. + assertThat(request.eventMap.get("event_type")).isEqualTo("TYPE_VIEW_CLICKED") + assertThat(request.eventMap.get("event_package_name")).isEqualTo("some.loooong.package.name") + assertThat(request.eventMap.get("source_content_description")) + .isEqualTo("My Content Description") + assertThat(request.eventMap.get("source_text")).isEqualTo("My Source Text") + assertThat(request.eventMap.get("source_class_name")).isEqualTo("AwesomeClass") + assertThat(request.eventMap.get("event_text")).isEqualTo("Some text!") + assertThat(request.eventMap.get("event_timestamp_ms")).isEqualTo("1357924680") + // No error message + "" + } + + // Act. + forwarder.onAccessibilityEvent(event) + + // Assert. + // See `sendEventChecker` above. + } + + @Test + fun onAccessibilityEvent_typeViewTextChanged_ensureAllFieldsForwarded() { + // Arrange. + createForwarder(logAccessibilityTree = false, grpcPort = 1234) + var nodeInfo = AccessibilityNodeInfo() + nodeInfo.setContentDescription("My Content Description") + nodeInfo.setText("My Source Text") + nodeInfo.setClassName("AwesomeClass") + var event = AccessibilityEvent() + event.setEventTime(1357924680) + event.setEventType(AccessibilityEvent.TYPE_VIEW_TEXT_CHANGED) + event.getText().add("Some text!") + event.fromIndex = 7 + event.beforeText = "Old words" + event.addedCount = 12 + event.removedCount = 9 + event.setPackageName("some.loooong.package.name") + shadowOf(event).setSourceNode(nodeInfo) + fakeA11yService.sendEventChecker = { request: EventRequest -> + // Check that all fields are consistent with how they were set above. + assertThat(request.eventMap.get("event_type")).isEqualTo("TYPE_VIEW_TEXT_CHANGED") + assertThat(request.eventMap.get("event_package_name")).isEqualTo("some.loooong.package.name") + assertThat(request.eventMap.get("source_content_description")) + .isEqualTo("My Content Description") + assertThat(request.eventMap.get("source_text")).isEqualTo("My Source Text") + assertThat(request.eventMap.get("source_class_name")).isEqualTo("AwesomeClass") + assertThat(request.eventMap.get("event_text")).isEqualTo("Some text!") + assertThat(request.eventMap.get("event_timestamp_ms")).isEqualTo("1357924680") + assertThat(request.eventMap.get("from_index")).isEqualTo("7") + assertThat(request.eventMap.get("before_text")).isEqualTo("Old words") + assertThat(request.eventMap.get("added_count")).isEqualTo("12") + assertThat(request.eventMap.get("removed_count")).isEqualTo("9") + assertFalse(request.eventMap.containsKey("to_index")) + assertFalse(request.eventMap.containsKey("view_id")) + assertFalse(request.eventMap.containsKey("action")) + assertFalse(request.eventMap.containsKey("movement_granularity")) + assertFalse(request.eventMap.containsKey("scroll_delta_x")) + assertFalse(request.eventMap.containsKey("scroll_delta_y")) + // No error message + "" + } + + // Act. + forwarder.onAccessibilityEvent(event) + + // Assert. + // See `sendEventChecker` above. + } + + @Test + fun onAccessibilityEvent_typeViewScrolled_ensureAllFieldsForwarded() { + // Arrange. + createForwarder(logAccessibilityTree = false, grpcPort = 1234) + var nodeInfo = AccessibilityNodeInfo() + nodeInfo.setContentDescription("My Content Description") + nodeInfo.setText("My Source Text") + nodeInfo.setClassName("AwesomeClass") + var event = AccessibilityEvent() + event.setEventTime(1357924680) + event.setEventType(AccessibilityEvent.TYPE_VIEW_SCROLLED) + event.getText().add("Some text!") + event.scrollDeltaX = 13 + event.scrollDeltaY = 27 + event.setPackageName("some.loooong.package.name") + shadowOf(event).setSourceNode(nodeInfo) + fakeA11yService.sendEventChecker = { request: EventRequest -> + // Check that all fields are consistent with how they were set above. + assertThat(request.eventMap.get("event_type")).isEqualTo("TYPE_VIEW_SCROLLED") + assertThat(request.eventMap.get("event_package_name")).isEqualTo("some.loooong.package.name") + assertThat(request.eventMap.get("source_content_description")) + .isEqualTo("My Content Description") + assertThat(request.eventMap.get("source_text")).isEqualTo("My Source Text") + assertThat(request.eventMap.get("source_class_name")).isEqualTo("AwesomeClass") + assertThat(request.eventMap.get("event_text")).isEqualTo("Some text!") + assertThat(request.eventMap.get("event_timestamp_ms")).isEqualTo("1357924680") + assertThat(request.eventMap.get("scroll_delta_x")).isEqualTo("13") + assertThat(request.eventMap.get("scroll_delta_y")).isEqualTo("27") + assertFalse(request.eventMap.containsKey("from_index")) + assertFalse(request.eventMap.containsKey("to_index")) + assertFalse(request.eventMap.containsKey("before_text")) + assertFalse(request.eventMap.containsKey("added_count")) + assertFalse(request.eventMap.containsKey("removed_count")) + // No error message + "" + } + + // Act. + forwarder.onAccessibilityEvent(event) + + // Assert. + // See `sendEventChecker` above. + } + + @Test + fun onAccessibilityEvent_typeViewTextTraversedAtMovementGranularity_ensureAllFieldsForwarded() { + // Arrange. + createForwarder(logAccessibilityTree = false, grpcPort = 1234) + var nodeInfo = AccessibilityNodeInfo() + nodeInfo.setContentDescription("My Content Description") + nodeInfo.setText("My Source Text") + nodeInfo.setClassName("AwesomeClass") + nodeInfo.viewIdResourceName = "this.big.old.view.id" + var event = AccessibilityEvent() + event.setEventTime(1357924680) + event.setEventType(AccessibilityEvent.TYPE_VIEW_TEXT_TRAVERSED_AT_MOVEMENT_GRANULARITY) + event.getText().add("Some text!") + event.setPackageName("some.loooong.package.name") + event.movementGranularity = 5 + event.fromIndex = 6 + event.toIndex = 8 + event.action = 23 + shadowOf(event).setSourceNode(nodeInfo) + fakeA11yService.sendEventChecker = { request: EventRequest -> + // Check that all fields are consistent with how they were set above. + assertThat(request.eventMap.get("event_type")) + .isEqualTo("TYPE_VIEW_TEXT_TRAVERSED_AT_MOVEMENT_GRANULARITY") + assertThat(request.eventMap.get("event_package_name")).isEqualTo("some.loooong.package.name") + assertThat(request.eventMap.get("source_content_description")) + .isEqualTo("My Content Description") + assertThat(request.eventMap.get("source_text")).isEqualTo("My Source Text") + assertThat(request.eventMap.get("source_class_name")).isEqualTo("AwesomeClass") + assertThat(request.eventMap.get("event_text")).isEqualTo("Some text!") + assertThat(request.eventMap.get("event_timestamp_ms")).isEqualTo("1357924680") + assertThat(request.eventMap.get("movement_granularity")).isEqualTo("5") + assertThat(request.eventMap.get("from_index")).isEqualTo("6") + assertThat(request.eventMap.get("to_index")).isEqualTo("8") + assertThat(request.eventMap.get("view_id")).isEqualTo("this.big.old.view.id") + assertThat(request.eventMap.get("action")).isEqualTo("23") + // No error message + "" + } + + // Act. + forwarder.onAccessibilityEvent(event) + + // Assert. + // See `sendEventChecker` above. + } + + @Test + fun onAccessibilityEvent_sendingevent_grpcTimeout() { + // Arrange. + createForwarder( + logAccessibilityTree = false, + a11yTreePeriodMs = 0, + grpcHost = "amazing.host", + grpcPort = 4321, + ) + var nodeInfo = AccessibilityNodeInfo() + nodeInfo.setContentDescription("My Content Description") + nodeInfo.setText("My Source Text") + nodeInfo.setClassName("AwesomeClass") + var event = AccessibilityEvent() + event.setEventTime(1357924680) + event.setEventType(AccessibilityEvent.TYPE_VIEW_CLICKED) + event.getText().add("Some text!") + event.setPackageName("some.loooong.package.name") + shadowOf(event).setSourceNode(nodeInfo) + fakeA11yService.sendEventChecker = { _ -> + // Delay the request to prompt a timeout + Thread.sleep(1500L) + "" // Return no error. + } + + // Act. + forwarder.onAccessibilityEvent(event) + + // Run a second request to ensure that the channel gets rebuilt. + fakeA11yService.sendEventChecker = { _ -> "" } + forwarder.onAccessibilityEvent(event) + + // Assert. + // See `sendEventChecker` above. + } + + @Test + fun onAccessibilityEvent_sendingevent_grpcStatusException() { + // Arrange. + createForwarder(logAccessibilityTree = false, grpcHost = "amazing.host", grpcPort = 4321) + var nodeInfo = AccessibilityNodeInfo() + nodeInfo.setContentDescription("My Content Description") + nodeInfo.setText("My Source Text") + nodeInfo.setClassName("AwesomeClass") + var event = AccessibilityEvent() + event.setEventTime(1357924680) + event.setEventType(AccessibilityEvent.TYPE_VIEW_CLICKED) + event.getText().add("Some text!") + event.setPackageName("some.loooong.package.name") + shadowOf(event).setSourceNode(nodeInfo) + fakeA11yService.sendEventChecker = { _ -> throw StatusException(Status.UNAVAILABLE) } + + // Act. + forwarder.onAccessibilityEvent(event) + + // Run a second request to ensure that the channel gets rebuilt. + fakeA11yService.sendEventChecker = { _ -> "" } + forwarder.onAccessibilityEvent(event) + + // Assert. + // See `sendEventChecker` above. + } + + @Test + fun logAccessibilityTreeFalse_doesNotLogAccessibilityTree() { + // Arrange. + createForwarder(logAccessibilityTree = false, a11yTreePeriodMs = 10, grpcPort = 13579) + fakeA11yService.sendForestChecker = { _: AndroidAccessibilityForest -> + assertFalse(true) // This should not be called. + "" // This should be unreachable + } + + // Act. + Thread.sleep(1000) // Sleep a bit to give time to trigger the tree logging function. + + // Assert. + // See `sendForestChecker` above. + } + + @Test + fun grpcPortZero_doesNotSendTree() { + // Arrange. + createForwarder(logAccessibilityTree = true, a11yTreePeriodMs = 10, grpcPort = 0) + fakeA11yService.sendForestChecker = { _: AndroidAccessibilityForest -> + assertFalse(true) // This should not be called. + "" // This should be unreachable + } + + // Act. + Thread.sleep(1000) // Sleep a bit to give time to trigger the tree logging function. + + // Assert. + // See `sendForestChecker` above. + } + + @Test + fun grpcPortPositive_shouldSendTreeViaGrpc() { + // Arrange. + val window = AccessibilityWindowInfo() + shadowOf(window).setType(AccessibilityWindowInfo.TYPE_SYSTEM) + createForwarder( + logAccessibilityTree = true, + a11yTreePeriodMs = 10, + grpcPort = 1234, + a11yWindows = mutableListOf(window), + ) + fakeA11yService.sendForestChecker = { request: AndroidAccessibilityForest -> + // Check that we get only a single window. + assertThat(request.windowsList.size).isEqualTo(1) + // And that its type is what we set above. + assertThat(request.windowsList[0].windowType) + .isEqualTo(AndroidAccessibilityWindowInfo.WindowType.TYPE_SYSTEM) + // The error message + "Something went wrong!" + } + + // Act. + Thread.sleep(1000) // Sleep a bit to give time to trigger the tree logging function. + + // Assert. + // See `sendForestChecker` above. + } + + @Test + fun grpcPortPositiveAndHost_shouldSendTreeViaGrpc() { + // Arrange. + fakeA11yService.sendForestChecker = { request: AndroidAccessibilityForest -> + // Check that we get only a single window. + assertThat(request.windowsList.size).isEqualTo(1) + // And that its type is what we set above. + assertThat(request.windowsList[0].windowType) + .isEqualTo(AndroidAccessibilityWindowInfo.WindowType.TYPE_ACCESSIBILITY_OVERLAY) + "" // Return no error. + } + val window = AccessibilityWindowInfo() + shadowOf(window).setType(AccessibilityWindowInfo.TYPE_ACCESSIBILITY_OVERLAY) + createForwarder( + logAccessibilityTree = true, + a11yTreePeriodMs = 500, + grpcHost = "amazing.host", + grpcPort = 4321, + a11yWindows = mutableListOf(window), + ) + + // Act. + Thread.sleep(1000) // Sleep a bit to give time to trigger the tree logging function. + + // Assert. + // See `sendForestChecker` above. + } + + @Test + fun sendingForest_grpcTimeout() { + // Arrange. + fakeA11yService.sendForestChecker = { _ -> + // Delay the request to prompt a timeout + Thread.sleep(1500L) + "" // Return no error. + } + val window = AccessibilityWindowInfo() + shadowOf(window).setType(AccessibilityWindowInfo.TYPE_ACCESSIBILITY_OVERLAY) + createForwarder( + logAccessibilityTree = true, + a11yTreePeriodMs = 10, + grpcHost = "amazing.host", + grpcPort = 4321, + a11yWindows = mutableListOf(window), + ) + + // Act. + Thread.sleep(2000) // Sleep a bit to give time to trigger the tree logging function. + + // Run a second request to ensure that the channel gets rebuilt. + fakeA11yService.sendForestChecker = { _ -> "" } + Thread.sleep(2000) // Sleep a bit to give time to trigger the tree logging function. + + // Assert. + // See `sendForestChecker` above. + } + + @Test + fun sendingForest_grpcStatusException() { + // Arrange. + val window = AccessibilityWindowInfo() + shadowOf(window).setType(AccessibilityWindowInfo.TYPE_ACCESSIBILITY_OVERLAY) + createForwarder( + logAccessibilityTree = true, + a11yTreePeriodMs = 10, + grpcHost = "amazing.host", + grpcPort = 4321, + a11yWindows = mutableListOf(window), + ) + fakeA11yService.sendForestChecker = { _ -> throw StatusException(Status.UNAVAILABLE) } + + // Act. + Thread.sleep(1000) // Sleep a bit to give time to trigger the tree logging function. + + // Run a second request to ensure that the channel gets rebuilt. + fakeA11yService.sendForestChecker = { _ -> "" } + Thread.sleep(1000) // Sleep a bit to give time to trigger the tree logging function. + + // Assert. + // See `sendForestChecker` above. + } +} diff --git a/android_env/apps/java/com/google/androidenv/accessibilityforwarder/AccessibilityTreeCreator.kt b/android_env/apps/java/com/google/androidenv/accessibilityforwarder/AccessibilityTreeCreator.kt new file mode 100644 index 00000000..87054118 --- /dev/null +++ b/android_env/apps/java/com/google/androidenv/accessibilityforwarder/AccessibilityTreeCreator.kt @@ -0,0 +1,235 @@ +// Copyright 2026 DeepMind Technologies Limited. +// +// 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.google.androidenv.accessibilityforwarder + +import android.graphics.Rect +import android.util.Log +import android.view.accessibility.AccessibilityNodeInfo +import android.view.accessibility.AccessibilityWindowInfo +import com.google.androidenv.accessibilityforwarder.AndroidAccessibilityWindowInfo.WindowType +import java.util.concurrent.ConcurrentHashMap +import java.util.stream.Collectors +import kotlin.collections.mutableListOf +import kotlinx.coroutines.Deferred +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.runBlocking + +/** Helper methods for creating the android accessibility info extra. */ +class AccessibilityTreeCreator() { + + /** Creates an accessibility forest proto. */ + fun buildForest(windowInfos: List): AndroidAccessibilityForest { + val sourcesMap: ConcurrentHashMap = + ConcurrentHashMap() + val windows: List = + processWindowsAndBlock(windowInfos, sourcesMap) + return androidAccessibilityForest { this.windows += windows } + } + + private fun processWindowsAndBlock( + windowInfos: List, + sourcesMap: ConcurrentHashMap, + ): List { + val windows: List + runBlocking { windows = processWindows(windowInfos, sourcesMap) } + return windows + } + + private suspend fun processWindows( + windowInfos: List, + sourcesMap: ConcurrentHashMap, + ): List { + var windowInfoProtos = mutableListOf() + for (i in windowInfos.size - 1 downTo 0) { + val windowInfoProto = processWindow(windowInfos.get(i), sourcesMap) + windowInfoProto?.let { windowInfoProtos.add(windowInfoProto) } + } + return windowInfoProtos.toList() + } + + private suspend fun processWindow( + windowInfo: AccessibilityWindowInfo, + sources: ConcurrentHashMap, + ): AndroidAccessibilityWindowInfo? { + val bounds = Rect() + windowInfo.getBoundsInScreen(bounds) + val root: AccessibilityNodeInfo? = windowInfo.root + if (root == null) { + Log.i(TAG, "window root is null") + return androidAccessibilityWindowInfo { + this.tree = androidAccessibilityTree {} + this.isActive = windowInfo.isActive + this.id = windowInfo.id + this.layer = windowInfo.layer + this.isAccessibilityFocused = windowInfo.isAccessibilityFocused + this.isFocused = windowInfo.isFocused + this.boundsInScreen = convertToRectProto(bounds) + this.windowType = toWindowType(windowInfo.type) + } + } + val treeDeferred: Deferred + runBlocking { treeDeferred = async { processNodesInWindow(root, sources) } } + return androidAccessibilityWindowInfo { + this.tree = treeDeferred.await() + this.isActive = windowInfo.isActive + this.id = windowInfo.id + this.layer = windowInfo.layer + this.isAccessibilityFocused = windowInfo.isAccessibilityFocused + this.isFocused = windowInfo.isFocused + this.boundsInScreen = convertToRectProto(bounds) + this.windowType = toWindowType(windowInfo.type) + } + } + + private suspend fun processNodesInWindow( + root: AccessibilityNodeInfo, + sources: ConcurrentHashMap, + ): AndroidAccessibilityTree { + Log.d(TAG, "processNodesInWindow()") + val traversalQueue = ArrayDeque() + traversalQueue.add(ParentChildNodePair.builder().child(root).build()) + val uniqueIdsCache: UniqueIdsGenerator = UniqueIdsGenerator() + var currentDepth = 0 + val nodesDeferred = mutableListOf>() + val seenNodes: HashSet = HashSet() + seenNodes.add(root) + runBlocking { + while (!traversalQueue.isEmpty()) { + // Traverse the tree layer-by-layer. + // The first layer has only the root and depth 0. + // The second layer has all the root's children and depth 1. + for (nodesAtCurrentDepth in traversalQueue.size downTo 1) { + val nodePair: ParentChildNodePair = traversalQueue.removeFirst() + for (i in 0 until nodePair.child().childCount) { + val childNode: AccessibilityNodeInfo? = nodePair.child().getChild(i) + if (childNode != null && !seenNodes.contains(childNode)) { + traversalQueue.add( + ParentChildNodePair.builder().child(childNode).parent(nodePair.child()).build() + ) + seenNodes.add(childNode) + } + } + val thisDepth = currentDepth + var deferred = async { processNode(nodePair, sources, uniqueIdsCache, thisDepth) } + nodesDeferred.add(deferred) + } + currentDepth++ + } + } + return androidAccessibilityTree { this.nodes += nodesDeferred.awaitAll() } + } + + companion object { + private const val TAG = "AndroidRLTask" + } +} + +private fun processNode( + nodePair: ParentChildNodePair, + sourceBuilder: ConcurrentHashMap, + uniqueIdsCache: UniqueIdsGenerator, + nodeDepth: Int, +): AndroidAccessibilityNodeInfo { + val node: AccessibilityNodeInfo = nodePair.child() + val immutableNode: AndroidAccessibilityNodeInfo = + createAndroidAccessibilityNode( + node, + uniqueIdsCache.getUniqueId(node), + nodeDepth, + getChildUniqueIds(node, uniqueIdsCache), + ) + sourceBuilder.put(immutableNode, node) + return immutableNode +} + +private fun createAndroidAccessibilityNode( + node: AccessibilityNodeInfo, + nodeId: Int, + depth: Int, + childIds: List, +): AndroidAccessibilityNodeInfo { + val bounds = Rect() + node.getBoundsInScreen(bounds) + val actions = node.getActionList().stream().map(::createAction).collect(Collectors.toList()) + return androidAccessibilityNodeInfo { + this.actions += actions + this.boundsInScreen = convertToRectProto(bounds) + this.isCheckable = node.isCheckable + this.isChecked = node.isChecked + this.className = stringFromNullableCharSequence(node.getClassName()) + this.isClickable = node.isClickable + this.contentDescription = stringFromNullableCharSequence(node.getContentDescription()) + this.isEditable = node.isEditable + this.isEnabled = node.isEnabled + this.isFocusable = node.isFocusable + this.hintText = stringFromNullableCharSequence(node.getHintText()) + this.isLongClickable = node.isLongClickable + this.packageName = stringFromNullableCharSequence(node.getPackageName()) + this.isPassword = node.isPassword + this.isScrollable = node.isScrollable + this.isSelected = node.isSelected + this.text = stringFromNullableCharSequence(node.getText()) + this.textSelectionEnd = node.getTextSelectionEnd().toLong() + this.textSelectionStart = node.getTextSelectionStart().toLong() + this.viewIdResourceName = node.getViewIdResourceName() ?: "" + this.isVisibleToUser = node.isVisibleToUser + this.windowId = node.windowId + this.uniqueId = nodeId + this.childIds += childIds + this.drawingOrder = node.drawingOrder + this.tooltipText = stringFromNullableCharSequence(node.getTooltipText()) + this.depth = depth + } +} + +private fun createAction( + action: AccessibilityNodeInfo.AccessibilityAction +): AndroidAccessibilityAction = + AndroidAccessibilityAction.newBuilder() + .setId(action.id) + .setLabel(stringFromNullableCharSequence(action.label)) + .build() + +private fun getChildUniqueIds( + node: AccessibilityNodeInfo, + uniqueIdsCache: UniqueIdsGenerator, +): List { + val ids = mutableListOf() + for (childId in 0 until node.getChildCount()) { + val child: AccessibilityNodeInfo = node.getChild(childId) ?: continue + ids.add(uniqueIdsCache.getUniqueId(child)) + } + return ids.toList() +} + +fun stringFromNullableCharSequence(cs: CharSequence?): String = cs?.toString() ?: "" + +fun convertToRectProto(rect: Rect) = protoRect { + left = rect.left + top = rect.top + right = rect.right + bottom = rect.bottom +} + +private fun toWindowType(type: Int): WindowType = + when (type) { + AccessibilityWindowInfo.TYPE_ACCESSIBILITY_OVERLAY -> WindowType.TYPE_ACCESSIBILITY_OVERLAY + AccessibilityWindowInfo.TYPE_APPLICATION -> WindowType.TYPE_APPLICATION + AccessibilityWindowInfo.TYPE_INPUT_METHOD -> WindowType.TYPE_INPUT_METHOD + AccessibilityWindowInfo.TYPE_SYSTEM -> WindowType.TYPE_SYSTEM + AccessibilityWindowInfo.TYPE_SPLIT_SCREEN_DIVIDER -> WindowType.TYPE_SPLIT_SCREEN_DIVIDER + else -> WindowType.UNKNOWN_TYPE + } diff --git a/android_env/apps/java/com/google/androidenv/accessibilityforwarder/AccessibilityTreeCreatorTest.kt b/android_env/apps/java/com/google/androidenv/accessibilityforwarder/AccessibilityTreeCreatorTest.kt new file mode 100644 index 00000000..0184b568 --- /dev/null +++ b/android_env/apps/java/com/google/androidenv/accessibilityforwarder/AccessibilityTreeCreatorTest.kt @@ -0,0 +1,85 @@ +// Copyright 2026 DeepMind Technologies Limited. +// +// 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.google.androidenv.accessibilityforwarder + +import android.view.accessibility.AccessibilityNodeInfo +import android.view.accessibility.AccessibilityWindowInfo +import kotlin.test.assertEquals +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.Shadows.shadowOf + +@RunWith(RobolectricTestRunner::class) +class AccessibilityTreeCreatorTest { + + @Test + fun buildForest_buildsAccessibilityForestCorrectly() { + val creator = AccessibilityTreeCreator() + + val forest = creator.buildForest(mutableListOf(createWindowInfo())) + + assertEquals(forest.windowsCount, 1) + assertEquals(forest.getWindows(0).tree.nodesCount, 3) + var rootNode: AndroidAccessibilityNodeInfo? = null + var checkableNode: AndroidAccessibilityNodeInfo? = null + val nodes = forest.getWindows(0).tree.nodesList + for (i in nodes.size - 1 downTo 0) { + if (nodes[i].text == "root node") { + rootNode = nodes[i] + } + if (nodes[i].isCheckable == true) { + checkableNode = nodes[i] + } + } + assertEquals(rootNode?.childIdsCount, 2) + assertEquals(checkableNode?.text, "Check box") + } + + @Test + fun buildForest_noRootInWindow_returnsEmptyTree() { + val creator = AccessibilityTreeCreator() + val windowInfo = AccessibilityWindowInfo.obtain() + shadowOf(windowInfo).setType(AccessibilityWindowInfo.TYPE_ACCESSIBILITY_OVERLAY) + + val forest = creator.buildForest(mutableListOf(windowInfo)) + + assertEquals(0, forest.getWindows(0).tree.nodesList.size) + } + + private fun createAccessibilityNodeInfo(): AccessibilityNodeInfo { + val root = AccessibilityNodeInfo.obtain() + root.text = "root node" + root.isClickable = true + val accessibilityNodeInfo = AccessibilityNodeInfo.obtain() + accessibilityNodeInfo.viewIdResourceName = "test" + accessibilityNodeInfo.isClickable = true + accessibilityNodeInfo.isEditable = true + accessibilityNodeInfo.hintText = "Please enter your address" + shadowOf(root).addChild(accessibilityNodeInfo) + val anotherChildNode = AccessibilityNodeInfo.obtain() + anotherChildNode.isCheckable = true + anotherChildNode.text = "Check box" + shadowOf(root).addChild(anotherChildNode) + return root + } + + private fun createWindowInfo(): AccessibilityWindowInfo { + val windowInfo = AccessibilityWindowInfo.obtain() + shadowOf(windowInfo).setType(AccessibilityWindowInfo.TYPE_ACCESSIBILITY_OVERLAY) + shadowOf(windowInfo).setRoot(createAccessibilityNodeInfo()) + return windowInfo + } +} diff --git a/android_env/apps/java/com/google/androidenv/accessibilityforwarder/AndroidManifest.xml b/android_env/apps/java/com/google/androidenv/accessibilityforwarder/AndroidManifest.xml new file mode 100644 index 00000000..07e4a860 --- /dev/null +++ b/android_env/apps/java/com/google/androidenv/accessibilityforwarder/AndroidManifest.xml @@ -0,0 +1,48 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android_env/apps/java/com/google/androidenv/accessibilityforwarder/AndroidManifest_lite.xml b/android_env/apps/java/com/google/androidenv/accessibilityforwarder/AndroidManifest_lite.xml new file mode 100644 index 00000000..1d53cadc --- /dev/null +++ b/android_env/apps/java/com/google/androidenv/accessibilityforwarder/AndroidManifest_lite.xml @@ -0,0 +1,24 @@ + + + + + + + + + diff --git a/android_env/apps/java/com/google/androidenv/accessibilityforwarder/FlagsBroadcastReceiver.kt b/android_env/apps/java/com/google/androidenv/accessibilityforwarder/FlagsBroadcastReceiver.kt new file mode 100644 index 00000000..64530203 --- /dev/null +++ b/android_env/apps/java/com/google/androidenv/accessibilityforwarder/FlagsBroadcastReceiver.kt @@ -0,0 +1,60 @@ +// Copyright 2026 DeepMind Technologies Limited. +// +// 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.google.androidenv.accessibilityforwarder + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.util.Log + +/** Broadcast receiver responsible for enabling or disabling flags. */ +class FlagsBroadcastReceiver() : BroadcastReceiver() { + + override fun onReceive(context: Context?, intent: Intent?) { + val action = intent?.action + Log.i(TAG, "Received broadcast intent with action: " + action) + when (action) { + ACTION_ENABLE_ACCESSIBILITY_TREE_LOGS -> { + Log.i(TAG, "Enabling Accessibility Tree logging.") + LogFlags.logAccessibilityTree = true + } + ACTION_DISABLE_ACCESSIBILITY_TREE_LOGS -> { + Log.i(TAG, "Disabling Accessibility Tree logging.") + LogFlags.logAccessibilityTree = false + } + ACTION_SET_GRPC -> { + // The Android Emulator uses 10.0.2.2 as a redirect to the workstation's IP. Most often the + // gRPC server will be running locally so it makes sense to use this as the default value. + // See https://developer.android.com/studio/run/emulator-networking#networkaddresses. + val host = intent.getStringExtra("host") ?: "10.0.2.2" + // The TCP port to connect. If <=0 gRPC is disabled. + val port = intent.getIntExtra("port", 0) + Log.i(TAG, "Setting gRPC endpoint to ${host}:${port}.") + LogFlags.grpcHost = host + LogFlags.grpcPort = port + } + else -> Log.w(TAG, "Unknown action: ${action}") + } + } + + companion object { + private const val TAG = "FlagsBroadcastReceiver" + private const val ACTION_ENABLE_ACCESSIBILITY_TREE_LOGS = + "accessibility_forwarder.intent.action.ENABLE_ACCESSIBILITY_TREE_LOGS" + private const val ACTION_DISABLE_ACCESSIBILITY_TREE_LOGS = + "accessibility_forwarder.intent.action.DISABLE_ACCESSIBILITY_TREE_LOGS" + private const val ACTION_SET_GRPC = "accessibility_forwarder.intent.action.SET_GRPC" + } +} diff --git a/android_env/apps/java/com/google/androidenv/accessibilityforwarder/FlagsBroadcastReceiverTest.kt b/android_env/apps/java/com/google/androidenv/accessibilityforwarder/FlagsBroadcastReceiverTest.kt new file mode 100644 index 00000000..f1708f91 --- /dev/null +++ b/android_env/apps/java/com/google/androidenv/accessibilityforwarder/FlagsBroadcastReceiverTest.kt @@ -0,0 +1,166 @@ +// Copyright 2026 DeepMind Technologies Limited. +// +// 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.google.androidenv.accessibilityforwarder + +import android.content.Intent +import com.google.common.truth.Truth.assertThat +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class FlagsBroadcastReceiverTest { + + @Test + fun onReceive_nullIntent_shouldNotLogAnything() { + // Arrange. + LogFlags.logAccessibilityTree = false + val receiver = FlagsBroadcastReceiver() + + // Act. + receiver.onReceive(context = null, intent = null) + + // Assert. + assertThat(LogFlags.logAccessibilityTree).isFalse() + } + + @Test + fun onReceive_nullIntent_actionShouldNotLogAnything() { + // Arrange. + LogFlags.logAccessibilityTree = false + val receiver = FlagsBroadcastReceiver() + val intent = Intent() + + // Act. + receiver.onReceive(context = null, intent = intent) + + // Assert. + assertThat(LogFlags.logAccessibilityTree).isFalse() + } + + @Test + fun onReceive_unknownIntent_actionShouldIssueWarning() { + // Arrange. + LogFlags.logAccessibilityTree = false + val receiver = FlagsBroadcastReceiver() + val intent = Intent("SOME_WEIRD_ACTION") + + // Act. + receiver.onReceive(context = null, intent = intent) + + // Assert. + assertThat(LogFlags.logAccessibilityTree).isFalse() + } + + @Test + fun onReceive_intentWithDisableAction_shouldDisableTreeLogging() { + // Arrange. + LogFlags.logAccessibilityTree = true + val receiver = FlagsBroadcastReceiver() + val intent = Intent("accessibility_forwarder.intent.action.DISABLE_ACCESSIBILITY_TREE_LOGS") + + // Act. + receiver.onReceive(context = null, intent = intent) + + // Assert. + assertThat(LogFlags.logAccessibilityTree).isFalse() + } + + @Test + fun onReceive_intentWithEnableAction_shouldEnableTreeLogging() { + // Arrange. + LogFlags.logAccessibilityTree = false + val receiver = FlagsBroadcastReceiver() + val intent = Intent("accessibility_forwarder.intent.action.ENABLE_ACCESSIBILITY_TREE_LOGS") + + // Act. + receiver.onReceive(context = null, intent = intent) + + // Assert. + assertThat(LogFlags.logAccessibilityTree).isTrue() + } + + @Test + fun onReceive_intentWithSetGrpcActionNoArgs_shouldDefaultToEmuIpAndPortZero() { + // Arrange. + LogFlags.grpcHost = "some_host" + LogFlags.grpcPort = 9999 + val receiver = FlagsBroadcastReceiver() + val intent = Intent("accessibility_forwarder.intent.action.SET_GRPC") + + // Act. + receiver.onReceive(context = null, intent = intent) + + // Assert. + assertThat(LogFlags.grpcHost).isEqualTo("10.0.2.2") + assertThat(LogFlags.grpcPort).isEqualTo(0) + } + + @Test + fun onReceive_intentWithSetGrpcActionWithHostNoPort_shouldDefaultPortToZero() { + // Arrange. + LogFlags.grpcHost = "some_host" + LogFlags.grpcPort = 9999 + val receiver = FlagsBroadcastReceiver() + val intent = + Intent("accessibility_forwarder.intent.action.SET_GRPC").apply { + putExtra("host", "awesome.server.ca") + } + + // Act. + receiver.onReceive(context = null, intent = intent) + + // Assert. + assertThat(LogFlags.grpcHost).isEqualTo("awesome.server.ca") + assertThat(LogFlags.grpcPort).isEqualTo(0) + } + + @Test + fun onReceive_intentWithSetGrpcActionWithPortNoHost_shouldDefaultHostToEmuIp() { + // Arrange. + LogFlags.grpcHost = "some_host" + LogFlags.grpcPort = 9999 + val receiver = FlagsBroadcastReceiver() + val intent = + Intent("accessibility_forwarder.intent.action.SET_GRPC").apply { putExtra("port", 54321) } + + // Act. + receiver.onReceive(context = null, intent = intent) + + // Assert. + assertThat(LogFlags.grpcHost).isEqualTo("10.0.2.2") + assertThat(LogFlags.grpcPort).isEqualTo(54321) + } + + @Test + fun onReceive_intentWithSetGrpcActionWithHostAndPort_shouldSetBoth() { + // Arrange. + LogFlags.grpcHost = "some_host" + LogFlags.grpcPort = 9999 + val receiver = FlagsBroadcastReceiver() + val intent = + Intent("accessibility_forwarder.intent.action.SET_GRPC").apply { + putExtra("host", "grpc.ca") + putExtra("port", 54321) + } + + // Act. + receiver.onReceive(context = null, intent = intent) + + // Assert. + assertThat(LogFlags.grpcHost).isEqualTo("grpc.ca") + assertThat(LogFlags.grpcPort).isEqualTo(54321) + } +} diff --git a/android_env/apps/java/com/google/androidenv/accessibilityforwarder/LogFlags.kt b/android_env/apps/java/com/google/androidenv/accessibilityforwarder/LogFlags.kt new file mode 100644 index 00000000..6482c8d2 --- /dev/null +++ b/android_env/apps/java/com/google/androidenv/accessibilityforwarder/LogFlags.kt @@ -0,0 +1,32 @@ +// Copyright 2026 DeepMind Technologies Limited. +// +// 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.google.androidenv.accessibilityforwarder + +/** + * Controls global settings in AccessibilityForwarder. + * + * Please note that this class is not thread safe. + */ +object LogFlags { + // Whether to log the accessibility tree. + var logAccessibilityTree: Boolean = false + // How frequent to emit a11y trees (in milliseconds). + var a11yTreePeriodMs: Long = 100 + + // The gRPC server to connect to. (Only available if grpcPort>0). + var grpcHost: String = "" + // If >0 this represents the gRPC port number to connect to. + var grpcPort: Int = 0 +} diff --git a/android_env/apps/java/com/google/androidenv/accessibilityforwarder/ParentChildNodePair.kt b/android_env/apps/java/com/google/androidenv/accessibilityforwarder/ParentChildNodePair.kt new file mode 100644 index 00000000..7bc7ac6b --- /dev/null +++ b/android_env/apps/java/com/google/androidenv/accessibilityforwarder/ParentChildNodePair.kt @@ -0,0 +1,40 @@ +// Copyright 2026 DeepMind Technologies Limited. +// +// 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.google.androidenv.accessibilityforwarder + +import android.view.accessibility.AccessibilityNodeInfo +import com.google.auto.value.AutoValue + +/** Parent and child [AccessibilityNodeInfo] relationship. */ +@AutoValue +internal abstract class ParentChildNodePair { + abstract fun parent(): AccessibilityNodeInfo? + + abstract fun child(): AccessibilityNodeInfo + + /** [ParentChildNodePair] builder. */ + @AutoValue.Builder + abstract class Builder { + abstract fun parent(parent: AccessibilityNodeInfo?): Builder + + abstract fun child(child: AccessibilityNodeInfo): Builder + + abstract fun build(): ParentChildNodePair + } + + companion object { + @JvmStatic fun builder(): Builder = AutoValue_ParentChildNodePair.Builder() + } +} diff --git a/android_env/apps/java/com/google/androidenv/accessibilityforwarder/UniqueIdsGenerator.kt b/android_env/apps/java/com/google/androidenv/accessibilityforwarder/UniqueIdsGenerator.kt new file mode 100644 index 00000000..ee5609dd --- /dev/null +++ b/android_env/apps/java/com/google/androidenv/accessibilityforwarder/UniqueIdsGenerator.kt @@ -0,0 +1,29 @@ +// Copyright 2026 DeepMind Technologies Limited. +// +// 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.google.androidenv.accessibilityforwarder + +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.atomic.AtomicInteger +import java.util.function.Function + +/** Thread-safe helper class for assigning a unique ID to an object. */ +internal class UniqueIdsGenerator { + private val nextId = AtomicInteger(0) + private val uniqueIdsByNode = ConcurrentHashMap() + + fun getUniqueId(a: A): Int { + return uniqueIdsByNode.computeIfAbsent(a, Function { _: A -> nextId.getAndIncrement() })!! + } +} diff --git a/android_env/apps/java/com/google/androidenv/accessibilityforwarder/res/xml/accessibility_forwarder_service.xml b/android_env/apps/java/com/google/androidenv/accessibilityforwarder/res/xml/accessibility_forwarder_service.xml new file mode 100644 index 00000000..37643f8d --- /dev/null +++ b/android_env/apps/java/com/google/androidenv/accessibilityforwarder/res/xml/accessibility_forwarder_service.xml @@ -0,0 +1,22 @@ + + + + + + diff --git a/android_env/apps/java/com/google/androidenv/catch/AndroidManifest.xml b/android_env/apps/java/com/google/androidenv/catch/AndroidManifest.xml new file mode 100644 index 00000000..95c55c9e --- /dev/null +++ b/android_env/apps/java/com/google/androidenv/catch/AndroidManifest.xml @@ -0,0 +1,39 @@ + + + + + + + + + + + + + + + diff --git a/android_env/apps/java/com/google/androidenv/catch/BUILD.bazel b/android_env/apps/java/com/google/androidenv/catch/BUILD.bazel new file mode 100644 index 00000000..da5fad41 --- /dev/null +++ b/android_env/apps/java/com/google/androidenv/catch/BUILD.bazel @@ -0,0 +1,80 @@ +# Copyright 2026 DeepMind Technologies Limited. +# +# 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. + +# Classic RL task implemented as an Android app. +load("@rules_android//rules:rules.bzl", "android_binary") +load("@rules_kotlin//kotlin:android.bzl", "kt_android_library") + +package( + default_visibility = [":catch_packages"], +) + +package_group( + name = "catch_packages", + packages = [ + "//java/com/google/androidenv/catch/...", + "//javatests/com/google/androidenv/catch/...", + ], +) + +licenses(["notice"]) + +android_binary( + name = "app", + manifest = "AndroidManifest.xml", + multidex = "native", + deps = [":MainActivity"], +) + +kt_android_library( + name = "GameLogic", + srcs = ["GameLogic.kt"], + deps = [ + "//java/com/google/androidenv/catch/sprite:Background", + "//java/com/google/androidenv/catch/sprite:Ball", + "//java/com/google/androidenv/catch/sprite:LineSegment", + "//java/com/google/androidenv/catch/sprite:Paddle", + ], +) + +kt_android_library( + name = "GameLogicThread", + srcs = ["GameLogicThread.kt"], + deps = [ + ":GameLogic", + ], +) + +kt_android_library( + name = "MainActivity", + srcs = ["MainActivity.kt"], + manifest = "AndroidManifest.xml", + resource_files = glob(["res/**"]), + deps = [ + ":GameLogic", + ":GameLogicThread", + ":RenderThread", + "//java/com/google/androidenv/catch/sprite:Background", + "//java/com/google/androidenv/catch/sprite:Ball", + "//java/com/google/androidenv/catch/sprite:Paddle", + ], +) + +kt_android_library( + name = "RenderThread", + srcs = ["RenderThread.kt"], + deps = [ + ":GameLogic", + ], +) diff --git a/android_env/apps/java/com/google/androidenv/catch/GameLogic.kt b/android_env/apps/java/com/google/androidenv/catch/GameLogic.kt new file mode 100644 index 00000000..78a1502b --- /dev/null +++ b/android_env/apps/java/com/google/androidenv/catch/GameLogic.kt @@ -0,0 +1,76 @@ +// Copyright 2026 DeepMind Technologies Limited. +// +// 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.google.androidenv.catch + +import android.graphics.Canvas +import android.view.MotionEvent +import com.google.androidenv.catch.sprite.Background +import com.google.androidenv.catch.sprite.Ball +import com.google.androidenv.catch.sprite.LineSegment +import com.google.androidenv.catch.sprite.Paddle +import java.time.Duration +import java.time.Instant +import kotlin.random.Random + +/** The class that contains the game logic. */ +open class GameLogic( + // Expected number of frames per second. + fps: Int = 60, + // Pseudo random number generator. + private val rand: Random = Random.Default, + // Width and height of the game in pixels. + private val width: Int, + private val height: Int, + // UI objects in the game. + private var background: Background = Background(), + private var ball: Ball = Ball(maxX = width, maxY = height, rand = rand), + private var paddle: Paddle = Paddle(maxX = width, y = height), +) { + + private val sleepTime: Duration = Duration.ofMillis((1000.0 / fps).toLong()) + + /** Reinitializes the state of the game. */ + // Need to make this open to allow for testing. + open fun reset() { + this.ball.reset() + } + + /** Runs one "throw" of a [ball] that needs to be caught by the [paddle]. */ + // Need to make this open to allow for testing. + open fun run(): Boolean { + var lastTimestamp = Instant.now() + do { + Thread.sleep(sleepTime.toMillis()) + val now = Instant.now() + val interval = Duration.between(lastTimestamp, now) + lastTimestamp = now + ball.update(interval) + } while (!ball.isOutOfBounds()) + + return ball.intersects(LineSegment(paddle.topLeft(), paddle.topRight())) + } + + /** Processes a user event (e.g. a touchscreen event) and updates the [paddle] accordingly. */ + fun handleTouch(event: MotionEvent) { + paddle.x = event.x.toInt() + } + + /** Renders the game on [c]. */ + open fun render(c: Canvas) { + background.draw(c) + ball.draw(c) + paddle.draw(c) + } +} diff --git a/android_env/apps/java/com/google/androidenv/catch/GameLogicThread.kt b/android_env/apps/java/com/google/androidenv/catch/GameLogicThread.kt new file mode 100644 index 00000000..f8baaa31 --- /dev/null +++ b/android_env/apps/java/com/google/androidenv/catch/GameLogicThread.kt @@ -0,0 +1,45 @@ +// Copyright 2026 DeepMind Technologies Limited. +// +// 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.google.androidenv.catch + +import android.util.Log + +/** A thread that continuously runs the game logic, resetting after each internal [run()]. */ +class GameLogicThread(private val game: GameLogic, private val loggingTag: String) : Thread() { + + /** Whether this thread should continuously run. */ + private var shouldRun: Boolean = true + /** A counter of game runs. */ + private var counter: Int = 0 + + /** + * Lets the current [run()] iteration complete then break exit this [Thread]. + * + * Notice that [shouldRun] cannot have a private getter with a public setter (please see + * https://youtrack.jetbrains.com/issue/KT-3110 for details), hence this public function. Also + * notice that we cannot call this function [stop()] since it would shadow [Thread.stop()]. + */ + public fun finish() { + shouldRun = false + } + + /** Continuously runs the [game] until [finish()] is called. */ + public override fun run() { + while (shouldRun) { + game.reset() + Log.i(loggingTag, "${counter++} - ${game.run()}") + } + } +} diff --git a/android_env/apps/java/com/google/androidenv/catch/MainActivity.kt b/android_env/apps/java/com/google/androidenv/catch/MainActivity.kt new file mode 100644 index 00000000..0800cd05 --- /dev/null +++ b/android_env/apps/java/com/google/androidenv/catch/MainActivity.kt @@ -0,0 +1,152 @@ +// Copyright 2026 DeepMind Technologies Limited. +// +// 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.google.androidenv.catch + +import android.app.Activity +import android.content.Intent +import android.graphics.Color +import android.os.Bundle +import android.util.Log +import android.view.SurfaceHolder +import android.view.SurfaceView +import android.view.View +import android.view.Window +import com.google.androidenv.catch.sprite.Background +import com.google.androidenv.catch.sprite.Ball +import com.google.androidenv.catch.sprite.Paddle + +/** The activity that allows users to play the RL game of Catch. */ +class MainActivity() : Activity(), SurfaceHolder.Callback { + + private var surfaceView: SurfaceView? = null + private var renderThread: RenderThread? = null + private var gameLogicThread: GameLogicThread? = null + + private val fps: Int = 60 + private var gameCounter: Int = 0 + private var width: Int = -1 + private var height: Int = -1 + + private var extras: Bundle? = null + + // [Activity] overrides. + + /** Initializes the Android [View] and sets up callbacks. */ + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + Log.i(TAG, "MainActivity::onCreate()") + requestWindowFeature(Window.FEATURE_NO_TITLE) + setContentView(R.layout.main) + val surface: SurfaceView? = findViewById(R.id.surfaceView) + if (surface == null) throw Exception("Could not create SurfaceView. Aborting...") + + surface.visibility = View.VISIBLE + surface.holder.addCallback(this) + surfaceView = surface + extras = intent?.extras + } + + override fun onNewIntent(intent: Intent?) { + super.onNewIntent(intent) + Log.i(TAG, "MainActivity::onNewIntent()") + extras = intent?.extras + startGame() + } + + // [SurfaceHolder.Callback] overrides. + + override fun surfaceCreated(holder: SurfaceHolder) { + Log.i(TAG, "MainActivity::surfaceCreated()") + renderThread = RenderThread(surfaceHolder = holder, fps = fps).also { it.start() } + } + + override fun surfaceChanged(holder: SurfaceHolder, format: Int, width: Int, height: Int) { + Log.i(TAG, "MainActivity::surfaceChanged()") + this.width = width + this.height = height + startGame() + } + + override fun surfaceDestroyed(holder: SurfaceHolder) { + Log.i(TAG, "MainActivity::surfaceDestroyed()") + renderThread?.finish() + renderThread?.join() + gameLogicThread?.finish() + gameLogicThread?.join() + } + + private fun startGame() { + Log.i(TAG, "MainActivity::startGame()") + if (width <= 0 || height <= 0) { + Log.e(TAG, "MainActivity::startGame() - Width or height not initialized yet.") + return + } + val backgroundColor = Color.parseColor(extras?.getString("backgroundColor") ?: "BLACK") + val ballColor = Color.parseColor(extras?.getString("ballColor") ?: "WHITE") + val ballRadius = extras?.getFloat("ballRadius", 10.0f) ?: 10.0f + val ballSpeed = extras?.getFloat("ballSpeed", 0.2f) ?: 0.2f + val paddleColor = Color.parseColor(extras?.getString("paddleColor") ?: "WHITE") + val paddleWidth = extras?.getInt("paddleWidth", 80) ?: 80 + val paddleHeight = extras?.getInt("paddleHeight", 10) ?: 10 + Log.i(TAG, "MainActivity::startGame() - extras bundle: $extras") + val game = + GameLogic( + width = width, + height = height, + fps = fps, + background = Background(color = backgroundColor), + ball = + Ball( + maxX = width, + maxY = height, + color = ballColor, + radius = ballRadius, + speed = ballSpeed, + ), + paddle = + Paddle( + color = paddleColor, + width = paddleWidth, + height = paddleHeight, + maxX = width, + y = (height - paddleHeight / 2), + ), + ) + + // Stop the previous game logic thread if it's running. + gameLogicThread?.finish() + gameLogicThread?.join() + + // Create and start the new GameLogicThread, passing the game instance. + gameLogicThread = GameLogicThread(game, TAG).also { it.start() } + + // Pass the same game instance to the render thread. + renderThread?.game = game + + surfaceView?.setOnTouchListener( + // Suppress warning for ClickableViewAccessibility since click handling + // is not within an OnTouchListener. + @SuppressWarnings("ClickableViewAccessibility") + View.OnTouchListener { _, motionEvent -> + game.handleTouch(motionEvent) + true + } + ) + } + + companion object { + private const val TAG = "AndroidRLTask" + } +} diff --git a/android_env/apps/java/com/google/androidenv/catch/RenderThread.kt b/android_env/apps/java/com/google/androidenv/catch/RenderThread.kt new file mode 100644 index 00000000..d5d2e5ce --- /dev/null +++ b/android_env/apps/java/com/google/androidenv/catch/RenderThread.kt @@ -0,0 +1,53 @@ +// Copyright 2026 DeepMind Technologies Limited. +// +// 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.google.androidenv.catch + +import android.graphics.Canvas +import android.view.SurfaceHolder +import java.time.Duration + +/** A thread that continuously renders the game logic onto a surface. */ +class RenderThread(private val surfaceHolder: SurfaceHolder, private val fps: Int = 60) : Thread() { + + /** Whether this thread should continuously run. */ + private var shouldRun: Boolean = true + /** How long to sleep at each [run()] iteration. */ + private val sleepTime: Duration = Duration.ofMillis((1000.0 / fps).toLong()) + /** The class responsible for issuing rendering commands to the canvas. */ + var game: GameLogic? = null + + /** + * Runs the current game logic [run()] to completion. + * + * Notice that [shouldRun] cannot have a private getter with a public setter (please see + * https://youtrack.jetbrains.com/issue/KT-3110 for details), hence this public function. Also + * notice that we cannot call this function [stop()] since it would shadow [Thread.stop()]. + */ + public fun finish() { + shouldRun = false + } + + /** Continuously renders the [game] onto [surfaceHolder]. */ + public override fun run() { + while (shouldRun) { + if (surfaceHolder.surface?.isValid() ?: false) { + val c: Canvas = surfaceHolder.lockCanvas() + game?.render(c) + surfaceHolder.unlockCanvasAndPost(c) + } + Thread.sleep(sleepTime.toMillis()) + } + } +} diff --git a/android_env/apps/java/com/google/androidenv/catch/res/layout/main.xml b/android_env/apps/java/com/google/androidenv/catch/res/layout/main.xml new file mode 100644 index 00000000..e07f6682 --- /dev/null +++ b/android_env/apps/java/com/google/androidenv/catch/res/layout/main.xml @@ -0,0 +1,27 @@ + + + + + + + diff --git a/android_env/apps/java/com/google/androidenv/catch/res/values/strings.xml b/android_env/apps/java/com/google/androidenv/catch/res/values/strings.xml new file mode 100644 index 00000000..4550e0de --- /dev/null +++ b/android_env/apps/java/com/google/androidenv/catch/res/values/strings.xml @@ -0,0 +1,19 @@ + + + + + + Catch + diff --git a/android_env/apps/java/com/google/androidenv/catch/sprite/BUILD.bazel b/android_env/apps/java/com/google/androidenv/catch/sprite/BUILD.bazel new file mode 100644 index 00000000..11b4ff80 --- /dev/null +++ b/android_env/apps/java/com/google/androidenv/catch/sprite/BUILD.bazel @@ -0,0 +1,64 @@ +# Copyright 2026 DeepMind Technologies Limited. +# +# 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. + +# Sprites for the app. + +load("@rules_kotlin//kotlin:android.bzl", "kt_android_library") + +package( + default_visibility = ["//java/com/google/androidenv/catch:catch_packages"], +) + +licenses(["notice"]) + +kt_android_library( + name = "Background", + srcs = ["Background.kt"], + deps = [":Sprite"], +) + +kt_android_library( + name = "Ball", + srcs = ["Ball.kt"], + deps = [ + ":LineSegment", + ":Point", + ":Sprite", + ], +) + +kt_android_library( + name = "LineSegment", + srcs = ["LineSegment.kt"], + deps = [":Point"], +) + +kt_android_library( + name = "Paddle", + srcs = ["Paddle.kt"], + deps = [ + ":Point", + ":Sprite", + ], +) + +kt_android_library( + name = "Point", + srcs = ["Point.kt"], +) + +kt_android_library( + name = "Sprite", + srcs = ["Sprite.kt"], +) diff --git a/android_env/apps/java/com/google/androidenv/catch/sprite/Background.kt b/android_env/apps/java/com/google/androidenv/catch/sprite/Background.kt new file mode 100644 index 00000000..fc7b0096 --- /dev/null +++ b/android_env/apps/java/com/google/androidenv/catch/sprite/Background.kt @@ -0,0 +1,26 @@ +// Copyright 2026 DeepMind Technologies Limited. +// +// 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.google.androidenv.catch.sprite + +import android.graphics.Canvas +import android.graphics.Color + +/** Represents the static background behind all objects. */ +open class Background(private val color: Int = Color.BLACK) : Sprite() { + /** Paints the canvas with the color given in the constructor. */ + override fun draw(c: Canvas) { + c.drawColor(color) + } +} diff --git a/android_env/apps/java/com/google/androidenv/catch/sprite/Ball.kt b/android_env/apps/java/com/google/androidenv/catch/sprite/Ball.kt new file mode 100644 index 00000000..1247ebd6 --- /dev/null +++ b/android_env/apps/java/com/google/androidenv/catch/sprite/Ball.kt @@ -0,0 +1,108 @@ +// Copyright 2026 DeepMind Technologies Limited. +// +// 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.google.androidenv.catch.sprite + +import android.graphics.Canvas +import android.graphics.Color +import android.graphics.Paint +import java.time.Duration +import kotlin.math.ceil +import kotlin.math.sqrt +import kotlin.random.Random + +/** Represents a ball that travels down in space with constant speed. */ +open class Ball( + private val maxX: Int, + private val maxY: Int, + private val color: Int = Color.WHITE, + private val radius: Float = 10.0f, + // `speed`'s unit is in pixels/ms. + private val speed: Float = 1.0f, + private val rand: Random = Random.Default, +) : Sprite() { + + // `x` and `y` represent the position of the center of this ball. + // + // Valid range [0, maxX]. 0==left, maxX==right. + private var x: Int = rand.nextInt(maxX) + // Valid range [0, maxY]. 0==top, maxY==bottom. + private var y: Int = ceil(radius).toInt() + + private val paint: Paint = + Paint(Paint.ANTI_ALIAS_FLAG).apply { + style = Paint.Style.FILL + color = (this@Ball).color + } + + /** Returns `true` if this ball intersects the given line [segment]. */ + fun intersects(segment: LineSegment): Boolean { + + /** A vector with two components. */ + data class Vector2D(val u: Int, val v: Int) { + /** Returns the dot product between two 2D vectors. */ + fun dot(other: Vector2D): Int = u * other.u + v * other.v + } + + /** Returns the vector representing [p] minus [q]. */ + fun pointDiff(p: Point, q: Point): Vector2D = Vector2D(p.x - q.x, p.y - q.y) + + val direction = pointDiff(segment.p1, segment.p0) // p0 -> p1. + val centerToP = pointDiff(segment.p0, Point(x, y)) // Ball center -> p0. + + // The `(centerToP + m * direction)` function models all the points in the line segment where + // the independent variable `m` is a real number in [0,1]. Putting this function into the + // formula for the circle (x ^ 2 + y ^ 2 = radius ^ 2) gives a quadratic equation + // (am^2 + bm + c = 0) where: + // [a] = direction · direction + // [b] = 2 centerToP · direction + // [c] = centerToP · centerToP - radius ^ 2 + val a = direction.dot(direction) + val b = 2 * centerToP.dot(direction) + val c = centerToP.dot(centerToP) - radius * radius + + val delta = b * b - 4 * a * c + if (delta < 0) + return false // No real roots means the (infinite) line does not intersect the ball. + + val d = sqrt(delta) + val m1 = (-b - d) / (2 * a) + val m2 = (-b + d) / (2 * a) + + // If a root is in [0,1], the line segment intersects the circumference. + // If [m1] < 0 and [m2] > 1, the line segment is "within" the circle meaning the circle + // intersects the infinite line, but not the line segment. In this case, we consider that it + // touched the ball. + return (m1 >= 0 && m1 <= 1) || (m2 >= 0 && m2 <= 1) || (m1 < 0 && m2 > 1) + } + + /** Places the ball at the top of the screen at a random x-coordinate. */ + fun reset() { + x = rand.nextInt(maxX) + y = ceil(radius).toInt() + } + + /** Moves the ball down by [timeDeltaMs]. */ + open fun update(timeDelta: Duration) { + y += (speed * timeDelta.toMillis()).toInt() + } + + /** Returns whether the ball is over [maxY]. */ + fun isOutOfBounds(): Boolean = y + radius > maxY || y - radius < 0 + + /** Draws this ball in `c`. */ + override fun draw(c: Canvas) { + c.drawCircle(x.toFloat(), y.toFloat(), radius, paint) + } +} diff --git a/android_env/apps/java/com/google/androidenv/catch/sprite/LineSegment.kt b/android_env/apps/java/com/google/androidenv/catch/sprite/LineSegment.kt new file mode 100644 index 00000000..56d37a6d --- /dev/null +++ b/android_env/apps/java/com/google/androidenv/catch/sprite/LineSegment.kt @@ -0,0 +1,18 @@ +// Copyright 2026 DeepMind Technologies Limited. +// +// 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.google.androidenv.catch.sprite + +/** Represents a finite line segment in 2D connected by two points [p0] and [p1]. */ +data class LineSegment(val p0: Point, val p1: Point) diff --git a/android_env/apps/java/com/google/androidenv/catch/sprite/Paddle.kt b/android_env/apps/java/com/google/androidenv/catch/sprite/Paddle.kt new file mode 100644 index 00000000..a7276adc --- /dev/null +++ b/android_env/apps/java/com/google/androidenv/catch/sprite/Paddle.kt @@ -0,0 +1,70 @@ +// Copyright 2026 DeepMind Technologies Limited. +// +// 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.google.androidenv.catch.sprite + +import android.graphics.Canvas +import android.graphics.Color +import android.graphics.Paint +import android.graphics.Rect +import kotlin.ranges.coerceIn + +/** Represents a paddle to hit/catch a falling ball. */ +open class Paddle( + private val color: Int = Color.WHITE, + // Width and height in pixels. + private val width: Int = 80, + private val height: Int = 10, + // maxX is the maximum X value for the center of the paddle. + private val maxX: Int = 100, + // The vertical position of the center of this paddle in pixels. + val y: Int = 100, +) : Sprite() { + + // Memoize a few things to make [draw()] a bit faster. + private val halfH = height / 2 + private val halfW = width / 2 + private val paint = + Paint(Paint.ANTI_ALIAS_FLAG).apply { + style = Paint.Style.FILL + color = (this@Paddle).color + } + + // The horizontal center of the paddle. + var x: Int = maxX / 2 // Start in the middle. + set(value) { + field = value.coerceIn(0, maxX) + } + + /** Returns the (x,y) coordinates of the top-left corner. */ + fun topLeft(): Point = Point(x - halfW, y - halfH) + + /** Returns the (x,y) coordinates of the top-right corner. */ + fun topRight(): Point = Point(x + halfW, y - halfH) + + fun move(deltaX: Int) { + x += deltaX + } + + override fun draw(c: Canvas) { + val rect = + Rect().apply { + bottom = y + halfH + top = y - halfH + left = x - halfW + right = x + halfW + } + c.drawRect(rect, paint) + } +} diff --git a/android_env/apps/java/com/google/androidenv/catch/sprite/Point.kt b/android_env/apps/java/com/google/androidenv/catch/sprite/Point.kt new file mode 100644 index 00000000..4e2e87eb --- /dev/null +++ b/android_env/apps/java/com/google/androidenv/catch/sprite/Point.kt @@ -0,0 +1,18 @@ +// Copyright 2026 DeepMind Technologies Limited. +// +// 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.google.androidenv.catch.sprite + +/** Represents a cartesian point in 2D. */ +data class Point(val x: Int, val y: Int) diff --git a/android_env/apps/java/com/google/androidenv/catch/sprite/Sprite.kt b/android_env/apps/java/com/google/androidenv/catch/sprite/Sprite.kt new file mode 100644 index 00000000..ed6ccc41 --- /dev/null +++ b/android_env/apps/java/com/google/androidenv/catch/sprite/Sprite.kt @@ -0,0 +1,24 @@ +// Copyright 2026 DeepMind Technologies Limited. +// +// 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.google.androidenv.catch.sprite + +import android.graphics.Canvas + +/** Represents something that can be drawn on the screen. */ +open class Sprite { + + /** Draws the Sprite in the given canvas. */ + open fun draw(c: Canvas) {} +} diff --git a/android_env/apps/javatests/com/google/androidenv/catch/AndroidManifest.xml b/android_env/apps/javatests/com/google/androidenv/catch/AndroidManifest.xml new file mode 100644 index 00000000..95c55c9e --- /dev/null +++ b/android_env/apps/javatests/com/google/androidenv/catch/AndroidManifest.xml @@ -0,0 +1,39 @@ + + + + + + + + + + + + + + + diff --git a/android_env/apps/javatests/com/google/androidenv/catch/BUILD.bazel b/android_env/apps/javatests/com/google/androidenv/catch/BUILD.bazel new file mode 100644 index 00000000..8862f3e9 --- /dev/null +++ b/android_env/apps/javatests/com/google/androidenv/catch/BUILD.bazel @@ -0,0 +1,87 @@ +# Copyright 2026 DeepMind Technologies Limited. +# +# 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. + +# Tests for the Android version of the RL Catch game. +load("@rules_kotlin//kotlin:android.bzl", "kt_android_local_test") +load("@rules_kotlin//kotlin:core.bzl", "kt_kotlinc_options") + +kt_kotlinc_options( + name = "kt_kotlinc_options", + jvm_target = "11", # Need to override default 1.8. + x_no_param_assertions = True, +) + +kt_android_local_test( + name = "GameLogicTest", + srcs = ["GameLogicTest.kt"], + kotlinc_opts = ":kt_kotlinc_options", + deps = [ + "//java/com/google/androidenv/catch:GameLogic", + "//java/com/google/androidenv/catch/sprite:Background", + "//java/com/google/androidenv/catch/sprite:Ball", + "//java/com/google/androidenv/catch/sprite:Paddle", + "@maven//:androidx_test_ext_junit", + "@maven//:androidx_test_runner", + "@maven//:com_google_truth_truth", + "@maven//:org_mockito_kotlin_mockito_kotlin", + "@maven//:org_robolectric_robolectric", + "@robolectric//bazel:android-all", + ], +) + +kt_android_local_test( + name = "GameLogicThreadTest", + srcs = ["GameLogicThreadTest.kt"], + kotlinc_opts = ":kt_kotlinc_options", + deps = [ + "//java/com/google/androidenv/catch:GameLogic", + "//java/com/google/androidenv/catch:GameLogicThread", + "@maven//:androidx_test_ext_junit", + "@maven//:com_google_truth_truth", + "@maven//:org_mockito_kotlin_mockito_kotlin", + "@maven//:org_robolectric_robolectric", + "@robolectric//bazel:android-all", + ], +) + +kt_android_local_test( + name = "MainActivityTest", + srcs = [ + "MainActivityTest.kt", + ], + kotlinc_opts = ":kt_kotlinc_options", + manifest = "AndroidManifest.xml", + deps = [ + "//java/com/google/androidenv/catch:MainActivity", + "@maven//:androidx_test_ext_junit", + "@maven//:junit_junit", + "@maven//:org_robolectric_robolectric", + "@robolectric//bazel:android-all", + ], +) + +kt_android_local_test( + name = "RenderThreadTest", + srcs = ["RenderThreadTest.kt"], + kotlinc_opts = ":kt_kotlinc_options", + deps = [ + "//java/com/google/androidenv/catch:GameLogic", + "//java/com/google/androidenv/catch:RenderThread", + "@maven//:androidx_test_ext_junit", + "@maven//:org_mockito_kotlin_mockito_kotlin", + "@maven//:org_mockito_mockito_core", + "@maven//:org_robolectric_robolectric", + "@robolectric//bazel:android-all", + ], +) diff --git a/android_env/apps/javatests/com/google/androidenv/catch/GameLogicTest.kt b/android_env/apps/javatests/com/google/androidenv/catch/GameLogicTest.kt new file mode 100644 index 00000000..b4e7d1d6 --- /dev/null +++ b/android_env/apps/javatests/com/google/androidenv/catch/GameLogicTest.kt @@ -0,0 +1,195 @@ +// Copyright 2026 DeepMind Technologies Limited. +// +// 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.google.androidenv.catch + +import android.graphics.Canvas +import androidx.test.core.view.MotionEventBuilder +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.google.androidenv.catch.sprite.Background +import com.google.androidenv.catch.sprite.Ball +import com.google.androidenv.catch.sprite.Paddle +import com.google.common.truth.Truth.assertThat +import java.time.Duration +import java.time.Instant +import kotlin.random.Random +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.kotlin.any +import org.mockito.kotlin.atLeast +import org.mockito.kotlin.atMost +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.mock +import org.mockito.kotlin.spy +import org.mockito.kotlin.times +import org.mockito.kotlin.verify + +@RunWith(AndroidJUnit4::class) +class GameLogicTest { + + @Test + fun run_ballIsMissed() { + // Arrange. + val width = 123 + val height = 33 + val mockRandom: Random = mock() { on { nextInt(any()) } doReturn 37 } + val game = + GameLogic( + rand = mockRandom, + width = width, + height = height, + ball = Ball(maxX = width, maxY = height, radius = 5.0f, rand = mockRandom), + paddle = Paddle(maxX = width, y = height, width = 3, height = 2), + ) + game.reset() + game.handleTouch( + MotionEventBuilder.newBuilder().setPointer(/* x= */ 12.0f, /* y= */ 31.0f).build() + ) + + // Act. + val outcome = game.run() // Ball falls at x==37, ev.x==12 so ball is missed. + + // Assert. + assertThat(outcome).isEqualTo(false) + } + + @Test + fun run_ballIsCaught() { + // Arrange. + val mockRandom: Random = mock() { on { nextInt(any()) } doReturn 53 } + val game = GameLogic(rand = mockRandom, width = 321, height = 47) + game.reset() + game.handleTouch( + MotionEventBuilder.newBuilder().setPointer(/* x= */ 53.0f, /* y= */ 43.0f).build() + ) + + // Act. + val outcome = game.run() // Ball falls at x==53, ev.x==53 so ball is caught. + + // Assert. + assertThat(outcome).isEqualTo(true) + } + + @Test + fun run_resetAllowsMultipleGamesToBePlayedWithASingleObjectAndDoesNotHang() { + // Arrange. + val mockRandom: Random = mock() + val game = GameLogic(width = 101, height = 59, rand = mockRandom) + + // Act. + repeat(17) { + game.reset() + val unused = game.run() // Ignore the outcome since we only care about run() terminating. + } + + // Assert. + // [rand.nextInt()] should be called once at construction and then 17 times for [reset()]. + verify(mockRandom, times(18)).nextInt(any()) + } + + @Test + fun run_inASeparateThread() { + // Arrange. + val mockRandom: Random = mock() { on { nextInt(any()) } doReturn 23 } + val game = GameLogic(rand = mockRandom, width = 321, height = 89) + game.reset() + game.handleTouch( + MotionEventBuilder.newBuilder().setPointer(/* x= */ 23.0f, /* y= */ 29.0f).build() + ) + var outcome: Boolean = false + + class MyThread(val g: GameLogic, var outcome: Boolean) : Thread() { + public override fun run() { + outcome = g.run() + } + } + val someThread = MyThread(game, outcome) + + // Act. + someThread.start() // Ball falls at x==23, ev.x==23 so ball is caught. + someThread.join() + + // Assert. + assertThat(outcome).isEqualTo(true) + } + + @Test + fun run_fpsLeadstoApproximatelyNumberOfElapsedTimeAndUpdateCalls() { + // Arrange. + val width = 123 + val height = 300 + val ball = spy(Ball(maxX = width, maxY = height, speed = 2.0f, radius = 1.0f)) + val game = GameLogic(fps = 100, width = width, height = height, ball = ball) + game.reset() + + // Act. + val start = Instant.now() + val unused = game.run() + val end = Instant.now() + + // Assert. + val elapsed = Duration.between(start, end) + // The ball should take around `height / speed = 150` milliseconds to reach the bottom. Due to + // timing non-determinism, we accept values between 100 and 200. + assertThat(elapsed.toMillis()).isAtLeast(100L) + assertThat(elapsed.toMillis()).isAtMost(200L) + // At fps==100, we expect [update()] to be called every `1000 / 100 = 10` milliseconds. We + // expect [elapsed] to be around 150ms (checked above) which should be around `150 / 10 = 15` + // calls, so to account for timing non-determinism we accept between 5 and 25 calls. + verify(ball, atLeast(5)).update(any()) + verify(ball, atMost(25)).update(any()) + } + + @Test + fun render_drawCanBeCalledMultipleTimesWithinASingleRun() { + // Arrange. + val width = 321 + val height = 89 + val mockCanvas: Canvas = mock() + val mockRandom: Random = mock() { on { nextInt(any()) } doReturn 23 } + val background = spy(Background()) + val paddle = spy(Paddle()) + val ball = spy(Ball(maxX = width, maxY = height)) + val game = + GameLogic( + rand = mockRandom, + width = width, + height = height, + background = background, + ball = ball, + paddle = paddle, + ) + game.reset() + game.handleTouch( + MotionEventBuilder.newBuilder().setPointer(/* x= */ 23.0f, /* y= */ 29.0f).build() + ) + + class MyThread(val g: GameLogic) : Thread() { + public override fun run() { + val unused = g.run() + } + } + val someThread = MyThread(game) + + // Act. + someThread.start() + repeat(11) { game.render(mockCanvas) } + someThread.join() + + // Assert. + verify(background, times(11)).draw(mockCanvas) + verify(ball, times(11)).draw(mockCanvas) + verify(paddle, times(11)).draw(mockCanvas) + } +} diff --git a/android_env/apps/javatests/com/google/androidenv/catch/GameLogicThreadTest.kt b/android_env/apps/javatests/com/google/androidenv/catch/GameLogicThreadTest.kt new file mode 100644 index 00000000..a2ea78c9 --- /dev/null +++ b/android_env/apps/javatests/com/google/androidenv/catch/GameLogicThreadTest.kt @@ -0,0 +1,72 @@ +// Copyright 2026 DeepMind Technologies Limited. +// +// 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.google.androidenv.catch + +import android.util.Log +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.google.common.truth.Truth.assertThat +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.kotlin.atLeastOnce +import org.mockito.kotlin.mock +import org.mockito.kotlin.verify +import org.robolectric.junit.rules.ExpectedLogMessagesRule + +@RunWith(AndroidJUnit4::class) +class GameLogicThreadTest { + + // Rule to assert log messages, taken as a reference from MainActivityTest.kt + @get:Rule val expectedLogMessagesRule = ExpectedLogMessagesRule() + + private val mockGame: GameLogic = mock() + private val testTag = "TestAndroidRLTask" + + @Test + fun run_iteratesGameAndLogs() { + // Arrange + val gameLogicThread = GameLogicThread(mockGame, testTag) + + // Act + gameLogicThread.start() + Thread.sleep(100) // Allow time for the thread to execute at least once. + gameLogicThread.finish() + gameLogicThread.join() // Wait for the thread to terminate. + + // Assert + // Verify that the game's core methods were called at least once. + verify(mockGame, atLeastOnce()).reset() + verify(mockGame, atLeastOnce()).run() + // Expect the log message from the run() loop. + // The mock 'game.run()' returns false by default. + expectedLogMessagesRule.expectLogMessage(Log.INFO, testTag, "0 - false") + } + + @Test + fun finish_stopsTheThread() { + // Arrange + val gameLogicThread = GameLogicThread(mockGame, testTag) + + // Act + gameLogicThread.start() + // Let it run for a moment before stopping it. + Thread.sleep(50) + gameLogicThread.finish() + gameLogicThread.join() + + // Assert + assertThat(gameLogicThread.isAlive).isFalse() + } +} diff --git a/android_env/apps/javatests/com/google/androidenv/catch/MainActivityTest.kt b/android_env/apps/javatests/com/google/androidenv/catch/MainActivityTest.kt new file mode 100644 index 00000000..d064b3c9 --- /dev/null +++ b/android_env/apps/javatests/com/google/androidenv/catch/MainActivityTest.kt @@ -0,0 +1,85 @@ +// Copyright 2026 DeepMind Technologies Limited. +// +// 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.google.androidenv.catch + +import android.content.Intent +import android.util.Log +import androidx.test.ext.junit.rules.ActivityScenarioRule +import androidx.test.ext.junit.runners.AndroidJUnit4 +import java.lang.reflect.Method +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.junit.rules.ExpectedLogMessagesRule + +@RunWith(AndroidJUnit4::class) +class MainActivityTest { + @get:Rule(order = 0) val activityScenarioRule = ActivityScenarioRule(MainActivity::class.java) + @get:Rule(order = 1) val expectedLogMessagesRule = ExpectedLogMessagesRule() + + @Before + fun setUp() { + expectedLogMessagesRule.expectLogMessage(Log.INFO, TAG, "MainActivity::onCreate()") + } + + @Test + fun surfaceChanged_logsStartsGame() { + activityScenarioRule.scenario.onActivity { activity -> + // Arrange. + val surfaceView = activity.findViewById(R.id.surfaceView) + val surfaceHolder = surfaceView.holder + + // Act - Trigger the surfaceChanged callback with positive width and height. + activity.surfaceChanged(surfaceHolder, 0, 100, 200) + + // Assert. + expectedLogMessagesRule.expectLogMessage(Log.INFO, TAG, "MainActivity::surfaceChanged()") + expectedLogMessagesRule.expectLogMessage(Log.INFO, TAG, "MainActivity::startGame()") + } + } + + @Test + fun onNewIntent_logsStartsGame_errorsOnUninitializedWidthOrHeight() { + // Arrange. + val newIntent = Intent() + // Find the onNewIntent method using reflection + val onNewIntentMethod: Method = + MainActivity::class.java.getDeclaredMethod("onNewIntent", Intent::class.java) + // Enable access to protected method + onNewIntentMethod.isAccessible = true + + activityScenarioRule.scenario.onActivity { activity -> + // Act - Invoke the onNewIntent method using reflection. + onNewIntentMethod.invoke(activity, newIntent) + + // Assert. + expectedLogMessagesRule.expectLogMessage(Log.INFO, TAG, "MainActivity::onNewIntent()") + expectedLogMessagesRule.expectLogMessage(Log.INFO, TAG, "MainActivity::startGame()") + // In this test case where we don't call surfaceChanged(), default width and height + // are -1 and should trigger this error to prevent Ball from initializing + // with invalid negative values, since nextInt() expects a positive number. + expectedLogMessagesRule.expectLogMessage( + Log.ERROR, + TAG, + "MainActivity::startGame() - Width or height not initialized yet.", + ) + } + } + + companion object { + private const val TAG = "AndroidRLTask" + } +} diff --git a/android_env/apps/javatests/com/google/androidenv/catch/RenderThreadTest.kt b/android_env/apps/javatests/com/google/androidenv/catch/RenderThreadTest.kt new file mode 100644 index 00000000..a65d8fb8 --- /dev/null +++ b/android_env/apps/javatests/com/google/androidenv/catch/RenderThreadTest.kt @@ -0,0 +1,136 @@ +// Copyright 2026 DeepMind Technologies Limited. +// +// 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.google.androidenv.catch + +import android.graphics.Canvas +import android.view.Surface +import android.view.SurfaceHolder +import androidx.test.ext.junit.runners.AndroidJUnit4 +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mockito.verifyNoInteractions +import org.mockito.kotlin.any +import org.mockito.kotlin.atLeast +import org.mockito.kotlin.atMost +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.mock +import org.mockito.kotlin.verify + +@RunWith(AndroidJUnit4::class) +class RenderThreadTest { + + @Test + fun run_finishBeforeStartResultsInNoRendering() { + // Arrange. + val surfaceHolder: SurfaceHolder = mock() + val renderThread = RenderThread(surfaceHolder = surfaceHolder, fps = 1000) + val game: GameLogic = mock() + renderThread.game = game + + // Act. + renderThread.finish() + renderThread.start() + + // Assert. + verifyNoInteractions(game) + verifyNoInteractions(surfaceHolder) + } + + @Test + fun run_startResultsInSomeRendering() { + // Arrange. + val canvas: Canvas = mock() + val surface: Surface = mock() { on { isValid() } doReturn true } + val surfaceHolder: SurfaceHolder = + mock() { + on { getSurface() } doReturn surface + on { lockCanvas() } doReturn canvas + } + val renderThread = RenderThread(surfaceHolder = surfaceHolder, fps = 1000) + val game: GameLogic = mock() + renderThread.game = game + + // Act. + renderThread.start() + Thread.sleep(/* millis= */ 500) // Sleep for at least one loop iteration. + renderThread.finish() + + // Assert. + verify(surfaceHolder, atLeast(1)).surface + verify(surfaceHolder, atLeast(1)).lockCanvas() + verify(surfaceHolder, atLeast(1)).unlockCanvasAndPost(any()) + verify(game, atLeast(1)).render(canvas) + } + + @Test + fun run_finishStopsRendering() { + // Arrange. + val canvas: Canvas = mock() + val surface: Surface = mock() { on { isValid() } doReturn true } + val surfaceHolder: SurfaceHolder = + mock() { + on { getSurface() } doReturn surface + on { lockCanvas() } doReturn canvas + } + val renderThread = RenderThread(surfaceHolder = surfaceHolder, fps = 20) + val game: GameLogic = mock() + renderThread.game = game + + // Act. + renderThread.start() + Thread.sleep(/* millis= */ 500) // Sleep for around 10 iterations + renderThread.finish() + Thread.sleep(/* millis= */ 500) // Sleep some more to ensure nothing runs after. + + // Assert. + verify(surfaceHolder, atLeast(1)).surface + verify(surfaceHolder, atLeast(1)).lockCanvas() + verify(surfaceHolder, atLeast(1)).unlockCanvasAndPost(any()) + // We expect [game.render()] to be executed for around 500 / (1000 / 20 = 50) = 10 times. To + // allow for some timing non-determinism we allow it to execute up to 15 times, but not more + // than that since [renderThread.finish()] should stop the thread from calling it. + verify(game, atLeast(1)).render(canvas) + verify(game, atMost(15)).render(canvas) + } + + @Test + fun run_expectedFramesPerSecond() { + // Arrange. + val canvas: Canvas = mock() + val surface: Surface = mock() { on { isValid() } doReturn true } + val surfaceHolder: SurfaceHolder = + mock() { + on { getSurface() } doReturn surface + on { lockCanvas() } doReturn canvas + } + val renderThread = RenderThread(surfaceHolder = surfaceHolder, fps = 5) + val game: GameLogic = mock() + renderThread.game = game + + // Act. + renderThread.start() + Thread.sleep(/* millis= */ 2000) // Sleep for around 10 loop iterations. + renderThread.finish() + + // Assert. + verify(surfaceHolder, atLeast(1)).surface + verify(surfaceHolder, atLeast(1)).lockCanvas() + verify(surfaceHolder, atLeast(1)).unlockCanvasAndPost(any()) + // We expect [game.render()] to be called around 2000ms / 5fps = 10 times but to account for + // timing non-determinism we allow ±4 iterations. + verify(game, atLeast(6)).render(canvas) + verify(game, atMost(14)).render(canvas) + } +} diff --git a/android_env/apps/javatests/com/google/androidenv/catch/sprite/BUILD.bazel b/android_env/apps/javatests/com/google/androidenv/catch/sprite/BUILD.bazel new file mode 100644 index 00000000..37939ecb --- /dev/null +++ b/android_env/apps/javatests/com/google/androidenv/catch/sprite/BUILD.bazel @@ -0,0 +1,82 @@ +# Copyright 2026 DeepMind Technologies Limited. +# +# 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. + +# Unit tests for Sprites in Catch. +load("@rules_kotlin//kotlin:android.bzl", "kt_android_local_test") +load("@rules_kotlin//kotlin:core.bzl", "kt_kotlinc_options") + +kt_kotlinc_options( + name = "kt_kotlinc_options", + jvm_target = "11", # Need to override default 1.8. + x_no_param_assertions = True, +) + +kt_android_local_test( + name = "BackgroundTest", + srcs = ["BackgroundTest.kt"], + kotlinc_opts = ":kt_kotlinc_options", + deps = [ + "//java/com/google/androidenv/catch/sprite:Background", + "@maven//:com_google_guava_guava", + "@maven//:com_google_testparameterinjector_test_parameter_injector", + "@maven//:org_mockito_kotlin_mockito_kotlin", + "@maven//:org_yaml_snakeyaml", + ], +) + +kt_android_local_test( + name = "BallTest", + srcs = ["BallTest.kt"], + kotlinc_opts = ":kt_kotlinc_options", + tags = ["robolectric"], + deps = [ + "//java/com/google/androidenv/catch/sprite:Ball", + "//java/com/google/androidenv/catch/sprite:LineSegment", + "//java/com/google/androidenv/catch/sprite:Point", + "@maven//:androidx_test_ext_junit", + "@maven//:com_google_guava_guava", + "@maven//:com_google_truth_truth", + "@maven//:org_mockito_kotlin_mockito_kotlin", + "@maven//:org_robolectric_robolectric", + "@robolectric//bazel:android-all", + ], +) + +kt_android_local_test( + name = "PaddleTest", + srcs = ["PaddleTest.kt"], + kotlinc_opts = ":kt_kotlinc_options", + tags = ["robolectric"], + deps = [ + "//java/com/google/androidenv/catch/sprite:Paddle", + "//java/com/google/androidenv/catch/sprite:Point", + "@maven//:androidx_test_ext_junit", + "@maven//:com_google_guava_guava", + "@maven//:com_google_truth_truth", + "@maven//:org_mockito_kotlin_mockito_kotlin", + "@maven//:org_robolectric_robolectric", + "@robolectric//bazel:android-all", + ], +) + +kt_android_local_test( + name = "SpriteTest", + srcs = ["SpriteTest.kt"], + kotlinc_opts = ":kt_kotlinc_options", + deps = [ + "//java/com/google/androidenv/catch/sprite:Sprite", + "@maven//:org_mockito_kotlin_mockito_kotlin", + "@maven//:org_mockito_mockito_core", + ], +) diff --git a/android_env/apps/javatests/com/google/androidenv/catch/sprite/BackgroundTest.kt b/android_env/apps/javatests/com/google/androidenv/catch/sprite/BackgroundTest.kt new file mode 100644 index 00000000..6341f492 --- /dev/null +++ b/android_env/apps/javatests/com/google/androidenv/catch/sprite/BackgroundTest.kt @@ -0,0 +1,58 @@ +// Copyright 2026 DeepMind Technologies Limited. +// +// 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.google.androidenv.catch.sprite + +import android.graphics.Canvas +import android.graphics.Color +import com.google.testing.junit.testparameterinjector.KotlinTestParameters.testValues +import com.google.testing.junit.testparameterinjector.TestParameter +import com.google.testing.junit.testparameterinjector.TestParameterInjector +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.kotlin.mock +import org.mockito.kotlin.times +import org.mockito.kotlin.verify + +@RunWith(TestParameterInjector::class) +class BackgroundTest { + + @Test + fun draw_defaultConstructorIsBlack() { + // Arrange. + val mockCanvas: Canvas = mock() + val background: Background = Background() + + // Act. + background.draw(mockCanvas) + + // Assert. + verify(mockCanvas, times(1)).drawColor(Color.BLACK) + } + + @Test + fun draw_customColors( + @TestParameter colorInt: Int = testValues(0, 255, 13_579, 2_468, 12_384_173) + ) { + // Arrange. + val mockCanvas: Canvas = mock() + val background: Background = Background(color = colorInt) + + // Act. + background.draw(mockCanvas) + + // Assert. + verify(mockCanvas, times(1)).drawColor(colorInt) + } +} diff --git a/android_env/apps/javatests/com/google/androidenv/catch/sprite/BallTest.kt b/android_env/apps/javatests/com/google/androidenv/catch/sprite/BallTest.kt new file mode 100644 index 00000000..3f96d923 --- /dev/null +++ b/android_env/apps/javatests/com/google/androidenv/catch/sprite/BallTest.kt @@ -0,0 +1,387 @@ +// Copyright 2026 DeepMind Technologies Limited. +// +// 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.google.androidenv.catch.sprite + +import android.graphics.Canvas +import android.graphics.Color +import android.graphics.Paint +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.google.common.truth.Truth.assertThat +import java.time.Duration +import kotlin.random.Random +import org.junit.Test +import org.junit.runner.RunWith +import org.junit.runners.Suite +import org.mockito.kotlin.any +import org.mockito.kotlin.argumentCaptor +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.eq +import org.mockito.kotlin.mock +import org.mockito.kotlin.verify +import org.robolectric.ParameterizedRobolectricTestRunner + +@RunWith(Suite::class) +@Suite.SuiteClasses( + BallTest.UpdateAndResetTests::class, + BallTest.ColorIntTest::class, + BallTest.CheckBoundsTest::class, + BallTest.IntersectsTest::class, +) +class BallTest { + + @RunWith(AndroidJUnit4::class) + class UpdateAndResetTests() { + @Test + fun isOutOfBounds_initialState_isFalse() { + // Arrange. + val mockRandom: Random = mock() { on { nextInt(any()) } doReturn 50 } // Horizontal middle. + with(Ball(maxX = 100, maxY = 100, radius = 3.0f, speed = 1.0f, rand = mockRandom)) { + assertThat(isOutOfBounds()).isEqualTo(false) + } + } + + @Test + fun isOutOfBounds_initialState_isTrueIfRadiusExceedsMaxY() { + // Arrange. + val mockRandom: Random = mock() { on { nextInt(any()) } doReturn 50 } // Horizontal middle. + with(Ball(maxX = 100, maxY = 10, radius = 11.0f, speed = 1.0f, rand = mockRandom)) { + assertThat(isOutOfBounds()).isEqualTo(true) + } + } + + @Test + fun isOutOfBounds_initialState_isFalseIfRadiusExceedsOnlyMaxX() { + // Arrange. + val mockRandom: Random = mock() { on { nextInt(any()) } doReturn 50 } // Horizontal middle. + with(Ball(maxX = 10, maxY = 100, radius = 11.0f, speed = 1.0f, rand = mockRandom)) { + assertThat(isOutOfBounds()).isEqualTo(false) + } + } + + @Test + fun update_zeroDurationDoesNotMove_withinBounds() { + // Arrange. + val mockRandom: Random = mock() { on { nextInt(any()) } doReturn 50 } // Horizontal middle. + with(Ball(maxX = 100, maxY = 100, radius = 3.0f, speed = 1.0f, rand = mockRandom)) { + // Act. + update(Duration.ofMillis(0)) // The ball should not move. + + // Assert. + assertThat(isOutOfBounds()).isEqualTo(false) // It should still be within the bounds. + } + } + + @Test + fun update_zeroDurationDoesNotMove_outOfBounds() { + // Arrange. + val mockRandom: Random = mock() { on { nextInt(any()) } doReturn 50 } // Horizontal middle. + with(Ball(maxX = 100, maxY = 100, radius = 3.0f, speed = 1.0f, rand = mockRandom)) { + update(Duration.ofMillis(110)) // Place the ball out of bounds. + assertThat(isOutOfBounds()).isEqualTo(true) + + // Act. + update(Duration.ofMillis(0)) // The ball should not move. + + // Assert. + assertThat(isOutOfBounds()).isEqualTo(true) // It should still be out of bounds. + } + } + + @Test + fun update_negativeDurationsMovesUp() { + // Arrange. + val mockRandom: Random = mock() { on { nextInt(any()) } doReturn 50 } // Horizontal middle. + with(Ball(maxX = 100, maxY = 100, radius = 3.0f, speed = 1.0f, rand = mockRandom)) { + update(Duration.ofMillis(30)) // Move the ball down 30 pixels. + assertThat(isOutOfBounds()).isEqualTo(false) + + // Act. + update(Duration.ofMillis(-50)) // Move the ball _up_ 50 pixels. + + // Assert. + assertThat(isOutOfBounds()).isEqualTo(true) // Now it should be out-of-bounds. + } + } + + @Test + fun update_singleThrow() { + // Ensures that a complete throw of a ball with radius==3.0f and maxY=100 behaves as expected. + // [isOutOfBounds()] should return [false] for the first (100-3.0f-3.0f)=94 [update()] calls, + // but [true] afterwards. + + // Arrange. + val mockRandom: Random = mock() { on { nextInt(any()) } doReturn 50 } // Horizontal middle. + with(Ball(maxX = 100, maxY = 100, radius = 3.0f, speed = 1.0f, rand = mockRandom)) { + // Act. + repeat(94) { + update(Duration.ofMillis(1)) + assertThat(isOutOfBounds()).isEqualTo(false) + } + update(Duration.ofMillis(1)) + + // Assert. + assertThat(isOutOfBounds()).isEqualTo(true) + } + } + + @Test + fun intersects_afterUpdate() { + // Arrange. + val mockRandom: Random = mock() { on { nextInt(any()) } doReturn 50 } // Horizontal middle. + + // Act & Assert. + with(Ball(maxX = 100, maxY = 100, radius = 3.0f, speed = 1.0f, rand = mockRandom)) { + assertThat(intersects(LineSegment(Point(40, 0), Point(60, 0)))).isEqualTo(true) + update(Duration.ofMillis(1)) + assertThat(intersects(LineSegment(Point(40, 0), Point(60, 0)))).isEqualTo(false) + } + } + + @Test + fun reset_intersectsInitialPositionShouldBeTrue() { + // Arrange. + val mockRandom: Random = mock() { on { nextInt(any()) } doReturn 50 } // Horizontal middle. + + with(Ball(maxX = 100, maxY = 100, radius = 3.0f, speed = 1.0f, rand = mockRandom)) { + // Act. + assertThat(intersects(LineSegment(Point(40, 0), Point(60, 0)))).isEqualTo(true) + + update(Duration.ofMillis(1)) // Move the ball 1 pixels down. + assertThat(intersects(LineSegment(Point(40, 0), Point(60, 0)))) + .isEqualTo(false) // Segment is now outside of the ball. + + reset() // Resetting should move the ball up again. + + // Assert. + assertThat(intersects(LineSegment(Point(40, 0), Point(60, 0)))) + .isEqualTo(true) // Segment is now inside of the ball. + } + } + + @Test + fun reset_differentInitialXCoordinates() { + // Arrange. + val ball: Ball = Ball(maxX = 100, maxY = 100, radius = 3.0f) + + // Act. + var pointInside: Boolean = false + var pointOutside: Boolean = false + while (!pointInside || !pointOutside) { + if (ball.intersects(LineSegment(Point(45, 0), Point(55, 0)))) { + pointInside = true + } else { + pointOutside = true + } + ball.reset() // Sample a new initial position for the ball. + } + + // Assert. + // Eventually after many initial positions the ball should satisfy both conditions. + assertThat(pointInside).isEqualTo(true) + assertThat(pointOutside).isEqualTo(true) + } + } + + @RunWith(ParameterizedRobolectricTestRunner::class) + class ColorIntTest(private val c: Int) { + + @Test + fun draw_customBallColors() { + // Arrange. + val mockRandom: Random = mock() { on { nextInt(any()) } doReturn 37 } + val mockCanvas: Canvas = mock() + val paintCaptor = argumentCaptor() + val ball: Ball = Ball(maxX = 50, maxY = 80, radius = 1.23f, color = c, rand = mockRandom) + + // Act. + ball.draw(mockCanvas) + + // Assert. + verify(mockCanvas).drawCircle(eq(37.0f), eq(2.0f), eq(1.23f), paintCaptor.capture()) + with(paintCaptor.lastValue) { + assertThat(color).isEqualTo(c) + assertThat(style).isEqualTo(Paint.Style.FILL) + } + } + + companion object { + @JvmStatic + @ParameterizedRobolectricTestRunner.Parameters(name = "color = {0}") + fun parameters() = listOf(0, 255, -1, 13579, 2468, 12384173, Color.WHITE, Color.BLUE) + } + } + + @RunWith(ParameterizedRobolectricTestRunner::class) + class CheckBoundsTest(private val p: ParamPack) { + + @Test + fun intersects_checkBounds() { + // Arrange. + val mockRandom: Random = + mock() { on { nextInt(any()) } doReturn p.maxX / 2 } // Horizontal middle. + + // Act. + val ball: Ball = Ball(maxX = p.maxX, maxY = p.maxY, radius = p.radius, rand = mockRandom) + + // Assert. + assertThat(ball.intersects(LineSegment(Point(p.x - 1, p.y), Point(p.x + 1, p.y)))) + .isEqualTo(p.expected) + } + + data class ParamPack( + val maxX: Int, + val maxY: Int, + val radius: Float, + val x: Int, + val y: Int, + val expected: Boolean, + ) + + companion object { + @JvmStatic + @ParameterizedRobolectricTestRunner.Parameters(name = "param = {0}") + fun parameters() = + listOf( + ParamPack( + maxX = 100, + maxY = 100, + radius = 10.0f, + x = 0, + y = 0, + expected = false, + ), // Ball to the right of `x`. + ParamPack( + maxX = 100, + maxY = 100, + radius = 10.0f, + x = 39, + y = 0, + expected = false, + ), // Ball to the right of `x`. + ParamPack( + maxX = 100, + maxY = 100, + radius = 10.0f, + x = 40, + y = 10, + expected = true, + ), // Ball contains `x`. + ParamPack( + maxX = 100, + maxY = 100, + radius = 10.0f, + x = 50, + y = 0, + expected = true, + ), // Ball contains `x`. + ParamPack( + maxX = 100, + maxY = 100, + radius = 10.0f, + x = 60, + y = 10, + expected = true, + ), // Ball contains `x`. + ParamPack( + maxX = 100, + maxY = 100, + radius = 10.0f, + x = 61, + y = 0, + expected = false, + ), // Ball to the left of `x`. + ParamPack( + maxX = 100, + maxY = 100, + radius = 10.0f, + x = 100, + y = 0, + expected = false, + ), // Ball to the left of `x`. + ParamPack( + maxX = 100, + maxY = 100, + radius = 10.0f, + x = 50, + y = 21, + expected = false, + ), // Ball above `y`. + ) + } + } + + @RunWith(ParameterizedRobolectricTestRunner::class) + class IntersectsTest(private val p: ParamPack) { + + @Test + fun intersects_ballAtx50y10radius10() { + // Arrange. + val mockRandom: Random = mock() { on { nextInt(any()) } doReturn 50 } // Horizontal middle. + + // Act. + val ball: Ball = Ball(maxX = 100, maxY = 100, radius = 10.0f, rand = mockRandom) + + // Assert. + assertThat(ball.intersects(p.segment)).isEqualTo(p.expected) + } + + data class ParamPack(val segment: LineSegment, val expected: Boolean) + + companion object { + @JvmStatic + @ParameterizedRobolectricTestRunner.Parameters(name = "param = {0}") + fun parameters() = + listOf( + ParamPack( + segment = LineSegment(Point(50, 10), Point(80, 40)), + expected = true, + ), // Segment that starts at the center of the ball so it should always intersect. + ParamPack( + segment = LineSegment(Point(49, 0), Point(51, 0)), + expected = true, + ), // Tangential segment that touches the bottom of the ball. + ParamPack( + segment = LineSegment(Point(40, 5), Point(65, 7)), + expected = true, + ), // Segment longer than diameter, touching the circumference twice. + ParamPack( + segment = LineSegment(Point(42, 2), Point(58, 1)), + expected = true, + ), // Segment shorter than diameter, touching the circumference twice. + ParamPack( + segment = LineSegment(Point(44, 4), Point(54, 3)), + expected = true, + ), // Segment shorter than diameter, fully inside the circle, not touching the + // circumference. + ParamPack( + segment = LineSegment(Point(35, 4), Point(54, 3)), + expected = true, + ), // Segment that touches the circumference once "from the left". + ParamPack( + segment = LineSegment(Point(54, 7), Point(67, 13)), + expected = true, + ), // Segment that touches the circumference once "from the right". + ParamPack( + segment = LineSegment(Point(36, 7), Point(45, 0)), + expected = false, + ), // Segment "to the left of the ball". No intersection. + ParamPack( + segment = LineSegment(Point(58, -3), Point(60, 3)), + expected = false, + ), // Segment "to the right of the ball". No intersection. + ) + } + } +} diff --git a/android_env/apps/javatests/com/google/androidenv/catch/sprite/PaddleTest.kt b/android_env/apps/javatests/com/google/androidenv/catch/sprite/PaddleTest.kt new file mode 100644 index 00000000..81f4e143 --- /dev/null +++ b/android_env/apps/javatests/com/google/androidenv/catch/sprite/PaddleTest.kt @@ -0,0 +1,184 @@ +// Copyright 2026 DeepMind Technologies Limited. +// +// 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.google.androidenv.catch.sprite + +import android.graphics.Canvas +import android.graphics.Color +import android.graphics.Paint +import android.graphics.Rect +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.google.common.truth.Truth.assertThat +import org.junit.Test +import org.junit.runner.RunWith +import org.junit.runners.Suite +import org.mockito.kotlin.argumentCaptor +import org.mockito.kotlin.mock +import org.mockito.kotlin.verify +import org.robolectric.ParameterizedRobolectricTestRunner + +@RunWith(Suite::class) +@Suite.SuiteClasses( + PaddleTest.ConstructorTests::class, + PaddleTest.MoveTests::class, + PaddleTest.XSetterTests::class, + PaddleTest.DrawTests::class, +) +class PaddleTest { + + @RunWith(AndroidJUnit4::class) + class ConstructorTests() { + + @Test + fun x_initialValueShouldBeAtCenter() { + with(Paddle(maxX = 30)) { assertThat(x).isEqualTo(15) } + with(Paddle(maxX = 31)) { assertThat(x).isEqualTo(15) } + } + + @Test + fun topLeft_correspondsToGivenValues() { + with(Paddle(width = 10, height = 6, maxX = 40, y = 33)) { + assertThat(topLeft()).isEqualTo(Point(x = 15, y = 30)) + } + } + + @Test + fun topRight_correspondsToGivenValues() { + with(Paddle(width = 10, height = 6, maxX = 40, y = 33)) { + assertThat(topRight()).isEqualTo(Point(x = 25, y = 30)) + } + } + } + + @RunWith(ParameterizedRobolectricTestRunner::class) + class MoveTests(private val p: ParamPack) { + + @Test + fun move_expectedDestination() { + // Arrange. + with(Paddle(maxX = 50)) { + // Act. + move(deltaX = p.displacement) + + // Assert. + assertThat(x).isEqualTo(p.expectedX) + } + } + + data class ParamPack(val displacement: Int, val expectedX: Int) + + companion object { + @JvmStatic + @ParameterizedRobolectricTestRunner.Parameters(name = "param = {0}") + fun parameters() = + listOf( + // Initial position is x==25. + ParamPack(displacement = 10, expectedX = 35), + ParamPack(displacement = -10, expectedX = 15), + ParamPack(displacement = 0, expectedX = 25), + // Going beyond the left and right walls should clamp the values to 0 and 50. + ParamPack(displacement = -26, expectedX = 0), + ParamPack(displacement = 26, expectedX = 50), + ) + } + } + + @RunWith(ParameterizedRobolectricTestRunner::class) + class XSetterTests(private val p: ParamPack) { + + @Test + fun xSetter_expectedDestination() { + // Arrange. + with(Paddle(maxX = 50)) { + // Act. + x = p.target + + // Assert. + assertThat(x).isEqualTo(p.expectedX) + } + } + + data class ParamPack(val target: Int, val expectedX: Int) + + companion object { + @JvmStatic + @ParameterizedRobolectricTestRunner.Parameters(name = "param = {0}") + fun parameters() = + listOf( + // Initial position is x==25. + ParamPack(target = 0, expectedX = 0), + ParamPack(target = 15, expectedX = 15), + ParamPack(target = 25, expectedX = 25), + ParamPack(target = 35, expectedX = 35), + ParamPack(target = 50, expectedX = 50), + // Going beyond the left and right walls should clamp the values to 0 and 50. + ParamPack(target = -1, expectedX = 0), + ParamPack(target = 51, expectedX = 50), + ) + } + } + + @RunWith(AndroidJUnit4::class) + class DrawTests() { + + @Test + fun draw_initialPosition() { + // Arrange. + val mockCanvas: Canvas = mock() + val rectCaptor = argumentCaptor() + val paintCaptor = argumentCaptor() + with(Paddle(color = Color.RED, width = 100, height = 20, maxX = 300, y = 400)) { + // Act. + draw(mockCanvas) + + // Assert. + assertThat(x).isEqualTo(150) + verify(mockCanvas).drawRect(rectCaptor.capture(), paintCaptor.capture()) + with(rectCaptor.lastValue) { + assertThat(bottom).isEqualTo(400 + 10) + assertThat(top).isEqualTo(400 - 10) + assertThat(left).isEqualTo(150 - 50) + assertThat(right).isEqualTo(150 + 50) + } + } + } + + @Test + fun draw_afterMove() { + // Arrange. + val mockCanvas: Canvas = mock() + val rectCaptor = argumentCaptor() + val paintCaptor = argumentCaptor() + with(Paddle(color = Color.RED, width = 100, height = 20, maxX = 300, y = 400)) { + // Act. + move(50) + draw(mockCanvas) + + // Assert. + assertThat(x).isEqualTo(200) + verify(mockCanvas).drawRect(rectCaptor.capture(), paintCaptor.capture()) + with(rectCaptor.lastValue) { + assertThat(bottom).isEqualTo(400 + 10) + assertThat(top).isEqualTo(400 - 10) + assertThat(left).isEqualTo(200 - 50) + assertThat(right).isEqualTo(200 + 50) + } + with(paintCaptor.lastValue) { + assertThat(color).isEqualTo(Color.RED) + assertThat(style).isEqualTo(Paint.Style.FILL) + } + } + } + } +} diff --git a/android_env/apps/javatests/com/google/androidenv/catch/sprite/SpriteTest.kt b/android_env/apps/javatests/com/google/androidenv/catch/sprite/SpriteTest.kt new file mode 100644 index 00000000..8eb846b1 --- /dev/null +++ b/android_env/apps/javatests/com/google/androidenv/catch/sprite/SpriteTest.kt @@ -0,0 +1,55 @@ +// Copyright 2026 DeepMind Technologies Limited. +// +// 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.google.androidenv.catch.sprite + +import android.graphics.Canvas +import org.junit.Test +import org.junit.runner.RunWith +import org.junit.runners.JUnit4 +import org.mockito.Mockito.verifyNoInteractions +import org.mockito.kotlin.mock +import org.mockito.kotlin.times +import org.mockito.kotlin.verify + +/** Trivial tests to ensure the types in the API are correct. */ +@RunWith(JUnit4::class) +class SpriteTest { + + @Test + fun defaultImplementationDoesNothing() { + // Arrange. + val mockCanvas: Canvas = mock() + val sprite = Sprite() + + // Act. + sprite.draw(mockCanvas) + + // Assert. + verifyNoInteractions(mockCanvas) // No methods should be called on the canvas. + } + + @Test + fun draw_argumentsAreForwarded() { + // Arrange. + val mockSprite: Sprite = mock() + val mockCanvas: Canvas = mock() + + // Act. + mockSprite.draw(mockCanvas) + + // Assert. + verify(mockSprite, times(1)).draw(mockCanvas) + } +}