From eb8f1d9e3c9d4efe24674537606504b1a9897b26 Mon Sep 17 00:00:00 2001 From: Himanshu Choudhary Date: Mon, 5 Jan 2026 05:46:34 +0000 Subject: [PATCH 1/3] Expose the underlying TestCoroutineScheduler According to the Kotlin Coroutines documentation, there should be only one `TestCoroutineScheduler` per test environment. This change exposes the scheduler used by the library, whether it was provided by the user or created by default. Access to this specific instance is necessary to ensure synchronization and enables the use of APIs like `runCurrent()`. Test: NA Bug: 254115946 Relnote: "Exposed the TestCoroutineScheduler to enable usage of `runCurrent()` and ensure access to the shared scheduler instance." Change-Id: Iea662686f25332bd583a4512827bb5eab0fc5ad9 --- compose/ui/ui-test/api/current.txt | 2 ++ compose/ui/ui-test/api/restricted_current.txt | 2 ++ compose/ui/ui-test/bcv/native/current.txt | 2 ++ .../androidx/compose/ui/test/MainTestClock.kt | 17 +++++++++++++++++ .../test/AbstractMainTestClock.jvmAndAndroid.kt | 2 +- 5 files changed, 24 insertions(+), 1 deletion(-) diff --git a/compose/ui/ui-test/api/current.txt b/compose/ui/ui-test/api/current.txt index 14af55484a062..0b51acc7ea6b4 100644 --- a/compose/ui/ui-test/api/current.txt +++ b/compose/ui/ui-test/api/current.txt @@ -489,9 +489,11 @@ package androidx.compose.ui.test { method @BytecodeOnly public static void advanceTimeUntil$default(androidx.compose.ui.test.MainTestClock!, long, kotlin.jvm.functions.Function0!, int, Object!); method @InaccessibleFromKotlin public boolean getAutoAdvance(); method @InaccessibleFromKotlin public long getCurrentTime(); + method @InaccessibleFromKotlin public default kotlinx.coroutines.test.TestCoroutineScheduler getScheduler(); method @InaccessibleFromKotlin public void setAutoAdvance(boolean); property public abstract boolean autoAdvance; property public abstract long currentTime; + property public default kotlinx.coroutines.test.TestCoroutineScheduler scheduler; } @kotlin.jvm.JvmInline public final value class MouseButton { diff --git a/compose/ui/ui-test/api/restricted_current.txt b/compose/ui/ui-test/api/restricted_current.txt index 06e3b004a646d..985825383856b 100644 --- a/compose/ui/ui-test/api/restricted_current.txt +++ b/compose/ui/ui-test/api/restricted_current.txt @@ -491,9 +491,11 @@ package androidx.compose.ui.test { method @BytecodeOnly public static void advanceTimeUntil$default(androidx.compose.ui.test.MainTestClock!, long, kotlin.jvm.functions.Function0!, int, Object!); method @InaccessibleFromKotlin public boolean getAutoAdvance(); method @InaccessibleFromKotlin public long getCurrentTime(); + method @InaccessibleFromKotlin public default kotlinx.coroutines.test.TestCoroutineScheduler getScheduler(); method @InaccessibleFromKotlin public void setAutoAdvance(boolean); property public abstract boolean autoAdvance; property public abstract long currentTime; + property public default kotlinx.coroutines.test.TestCoroutineScheduler scheduler; } @kotlin.jvm.JvmInline public final value class MouseButton { diff --git a/compose/ui/ui-test/bcv/native/current.txt b/compose/ui/ui-test/bcv/native/current.txt index 0c4518c4dd3c6..108804070feba 100644 --- a/compose/ui/ui-test/bcv/native/current.txt +++ b/compose/ui/ui-test/bcv/native/current.txt @@ -82,6 +82,8 @@ abstract interface androidx.compose.ui.test/KeyInjectionScope : androidx.compose abstract interface androidx.compose.ui.test/MainTestClock { // androidx.compose.ui.test/MainTestClock|null[0] abstract val currentTime // androidx.compose.ui.test/MainTestClock.currentTime|{}currentTime[0] abstract fun (): kotlin/Long // androidx.compose.ui.test/MainTestClock.currentTime.|(){}[0] + open val scheduler // androidx.compose.ui.test/MainTestClock.scheduler|{}scheduler[0] + open fun (): kotlinx.coroutines.test/TestCoroutineScheduler // androidx.compose.ui.test/MainTestClock.scheduler.|(){}[0] abstract var autoAdvance // androidx.compose.ui.test/MainTestClock.autoAdvance|{}autoAdvance[0] abstract fun (): kotlin/Boolean // androidx.compose.ui.test/MainTestClock.autoAdvance.|(){}[0] diff --git a/compose/ui/ui-test/src/commonMain/kotlin/androidx/compose/ui/test/MainTestClock.kt b/compose/ui/ui-test/src/commonMain/kotlin/androidx/compose/ui/test/MainTestClock.kt index 667d8cb66f68d..d94549712620d 100644 --- a/compose/ui/ui-test/src/commonMain/kotlin/androidx/compose/ui/test/MainTestClock.kt +++ b/compose/ui/ui-test/src/commonMain/kotlin/androidx/compose/ui/test/MainTestClock.kt @@ -20,6 +20,7 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.MonotonicFrameClock import androidx.compose.runtime.Recomposer import androidx.compose.ui.test.internal.JvmDefaultWithCompatibility +import kotlinx.coroutines.test.TestCoroutineScheduler /** * The clock that drives [frames][MonotonicFrameClock.withFrameNanos], [recompositions][Recomposer] @@ -81,6 +82,22 @@ interface MainTestClock { /** The current time of this clock in milliseconds. */ val currentTime: Long + /** + * The [TestCoroutineScheduler] on which this clock is built. It drives the execution of + * coroutines within the composition and is used to dispatch coroutines inside the [Recomposer] + * and for [LaunchedEffect]s. + * + * If the test was started with a custom `effectContext` containing a + * [kotlinx.coroutines.test.TestDispatcher], this returns the scheduler from that dispatcher. + * Otherwise, an internally managed [TestCoroutineScheduler] is created. + */ + val scheduler: TestCoroutineScheduler + get() = + throw NotImplementedError( + "Implement by returning the TestCoroutineScheduler on which the Recomposer is " + + "scheduled" + ) + /** * Whether the clock should be advanced by the testing framework while awaiting idleness in * order to process any pending work that is driven by this clock. This ensures that when the diff --git a/compose/ui/ui-test/src/jvmAndAndroidMain/kotlin/androidx/compose/ui/test/AbstractMainTestClock.jvmAndAndroid.kt b/compose/ui/ui-test/src/jvmAndAndroidMain/kotlin/androidx/compose/ui/test/AbstractMainTestClock.jvmAndAndroid.kt index 37ca33d0a11c2..70f374451a8db 100644 --- a/compose/ui/ui-test/src/jvmAndAndroidMain/kotlin/androidx/compose/ui/test/AbstractMainTestClock.jvmAndAndroid.kt +++ b/compose/ui/ui-test/src/jvmAndAndroidMain/kotlin/androidx/compose/ui/test/AbstractMainTestClock.jvmAndAndroid.kt @@ -26,7 +26,7 @@ internal abstract class AbstractMainTestClock( * The underlying scheduler which this clock controls. Only advance the time or run current * tasks from the scheduler on the UI thread, as any task could be a UI thread only task. */ - private val scheduler: TestCoroutineScheduler, + override val scheduler: TestCoroutineScheduler, private val frameDelayMillis: Long, private val isStandardTestDispatcherSupportEnabled: Boolean, private val runOnUiThread: (action: () -> Unit) -> Unit, From 972504ad938114fd7be8954a6f6e7cbcfcabcf90 Mon Sep 17 00:00:00 2001 From: Bhavya Jain Date: Tue, 13 Jan 2026 08:39:30 +0000 Subject: [PATCH 2/3] Refactoring the touch handler and listener for annotation hitting. This CL introduces the following changes - 1. It decouples the tight dependency on AnnotationsView so that it can unit tested without instrumentation testing requirements. 2. Renames the handleTouch method to findAnnotations which is more idiomatic. 3. Renames the touch handler to AnnotationsLocator. Removes the setListener method and returns the list of annotations instead. 4. The locator returns a list of annotations so that its usage could be broader at the same point. eg. multiple annotation selection 5. Cleaning up AnnotationsView so the locator reinitialises when the pageInfoProvider initialises as well. Bug: 475418945 Test: ./gradlew :pdf:pdf-viewer:test Change-Id: I2ff98696f027e5fbc8977a6dec96346f6c707da2 --- .../pdf/ink/EditablePdfViewerFragment.kt | 14 +- .../AnnotationSelectionTouchHandlerTest.kt | 245 ------------------ .../pdf/annotation/AnnotationsLocatorTest.kt | 167 ++++++++++++ .../pdf/annotation/AnnotationsViewTest.kt | 8 +- ....kt => FakeOnAnnotationLocatedListener.kt} | 4 +- ...nTouchHandler.kt => AnnotationsLocator.kt} | 64 +++-- .../pdf/annotation/AnnotationsView.kt | 52 ++-- .../pdf/annotation/LocatedAnnotations.kt | 54 ++++ 8 files changed, 294 insertions(+), 314 deletions(-) delete mode 100644 pdf/pdf-viewer/src/androidTest/kotlin/androidx/pdf/annotation/AnnotationSelectionTouchHandlerTest.kt create mode 100644 pdf/pdf-viewer/src/androidTest/kotlin/androidx/pdf/annotation/AnnotationsLocatorTest.kt rename pdf/pdf-viewer/src/androidTest/kotlin/androidx/pdf/annotation/{FakeOnAnnotationSelectedListener.kt => FakeOnAnnotationLocatedListener.kt} (81%) rename pdf/pdf-viewer/src/main/kotlin/androidx/pdf/annotation/{AnnotationSelectionTouchHandler.kt => AnnotationsLocator.kt} (67%) create mode 100644 pdf/pdf-viewer/src/main/kotlin/androidx/pdf/annotation/LocatedAnnotations.kt diff --git a/pdf/pdf-ink/src/main/kotlin/androidx/pdf/ink/EditablePdfViewerFragment.kt b/pdf/pdf-ink/src/main/kotlin/androidx/pdf/ink/EditablePdfViewerFragment.kt index 8205e5971021b..f3c67c48629e6 100644 --- a/pdf/pdf-ink/src/main/kotlin/androidx/pdf/ink/EditablePdfViewerFragment.kt +++ b/pdf/pdf-ink/src/main/kotlin/androidx/pdf/ink/EditablePdfViewerFragment.kt @@ -46,7 +46,8 @@ import androidx.pdf.PdfWriteHandle import androidx.pdf.annotation.AnnotationsView import androidx.pdf.annotation.AnnotationsView.PageAnnotationsData import androidx.pdf.annotation.KeyedPdfAnnotation -import androidx.pdf.annotation.OnAnnotationSelectedListener +import androidx.pdf.annotation.LocatedAnnotations +import androidx.pdf.annotation.OnAnnotationLocatedListener import androidx.pdf.annotation.highlights.InProgressTextHighlightsListener import androidx.pdf.annotation.highlights.models.InProgressHighlightId import androidx.pdf.annotation.models.AnnotationsDisplayState @@ -257,11 +258,12 @@ public open class EditablePdfViewerFragment : PdfViewerFragment { } } - private val onAnnotationSelectedListener = - object : OnAnnotationSelectedListener { - override fun onAnnotationSelected(keyedPdfAnnotation: KeyedPdfAnnotation) { + private val onAnnotationLocatedListener = + object : OnAnnotationLocatedListener { + override fun onAnnotationsLocated(locatedAnnotations: LocatedAnnotations) { if (documentViewModel.drawingMode.value == AnnotationDrawingMode.EraserMode) { - documentViewModel.removeAnnotation(keyedPdfAnnotation.key) + val topAnnotation = locatedAnnotations.annotations.first() + documentViewModel.removeAnnotation(topAnnotation.key) } } } @@ -350,7 +352,7 @@ public open class EditablePdfViewerFragment : PdfViewerFragment { private fun setupAnnotationViewListeners() { annotationView.pageInfoProvider = pageInfoProvider - annotationView.addOnAnnotationSelectedListener(onAnnotationSelectedListener) + annotationView.addOnAnnotationLocatedListener(onAnnotationLocatedListener) annotationView.addInProgressTextHighlightsListener(inProgressTextHighlightsListener) } diff --git a/pdf/pdf-viewer/src/androidTest/kotlin/androidx/pdf/annotation/AnnotationSelectionTouchHandlerTest.kt b/pdf/pdf-viewer/src/androidTest/kotlin/androidx/pdf/annotation/AnnotationSelectionTouchHandlerTest.kt deleted file mode 100644 index 1ea451cf8bc78..0000000000000 --- a/pdf/pdf-viewer/src/androidTest/kotlin/androidx/pdf/annotation/AnnotationSelectionTouchHandlerTest.kt +++ /dev/null @@ -1,245 +0,0 @@ -/* - * Copyright 2025 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package androidx.pdf.annotation - -import android.graphics.Color -import android.graphics.Matrix -import android.graphics.RectF -import android.os.SystemClock -import android.util.SparseArray -import android.view.MotionEvent -import android.view.ViewGroup -import android.widget.FrameLayout -import androidx.pdf.annotation.AnnotationsView.PageAnnotationsData -import androidx.pdf.annotation.models.PathPdfObject -import androidx.pdf.annotation.models.PdfAnnotation -import androidx.pdf.annotation.models.StampAnnotation -import androidx.pdf.view.PdfViewTestActivity -import androidx.test.core.app.ActivityScenario -import androidx.test.ext.junit.runners.AndroidJUnit4 -import androidx.test.filters.LargeTest -import com.google.common.truth.Truth.assertThat -import java.util.UUID -import org.junit.After -import org.junit.Before -import org.junit.Test -import org.junit.runner.RunWith - -@LargeTest -@RunWith(AndroidJUnit4::class) -class AnnotationSelectionTouchHandlerTest { - private lateinit var annotationsView: AnnotationsView - private lateinit var selectionTouchHandler: AnnotationSelectionTouchHandler - private val testListener = FakeAnnotationSelectedListener() - - @Before - fun setUp() { - PdfViewTestActivity.onCreateCallback = { activity -> - // Setup AnnotationsView - annotationsView = - AnnotationsView(activity).apply { - layoutParams = - FrameLayout.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.MATCH_PARENT, - ) - } - activity.container.addView(annotationsView) - - // Setup Handler with a Fake Provider mapping 1:1 to view coordinates - val pageInfoProvider = - object : PageInfoProvider { - override fun getPageInfoFromViewCoordinates( - viewX: Float, - viewY: Float, - ): PageInfoProvider.PageInfo { - return PageInfoProvider.PageInfo( - pageNum = 0, - pageBounds = RectF(0f, 0f, PAGE_WIDTH, PAGE_HEIGHT), - pageToViewTransform = Matrix(), - viewToPageTransform = Matrix(), - ) - } - } - - annotationsView.pageInfoProvider = pageInfoProvider - selectionTouchHandler = AnnotationSelectionTouchHandler() - selectionTouchHandler.setListener(testListener) - } - } - - @After - fun tearDown() { - PdfViewTestActivity.onCreateCallback = {} - } - - @Test - fun handleTouchEvent_hitDetected_notifiesListener() { - ActivityScenario.launch(PdfViewTestActivity::class.java).use { scenario -> - val annotation = createStampAnnotation() - - scenario.onActivity { - setupAnnotationsOnView(listOf(annotation)) - - // Touch at (150, 150) is inside the first path (125, 125 to 175, 175) - val event = obtainMotionEvent(MotionEvent.ACTION_DOWN, 150f, 150f) - val consumed = selectionTouchHandler.handleTouch(annotationsView, event) - - assertThat(consumed).isTrue() - assertThat(testListener.lastSelectedAnnotation).isEqualTo(annotation) - } - } - } - - @Test - fun handleTouchEvent_outsideBounds_returnsFalse() { - ActivityScenario.launch(PdfViewTestActivity::class.java).use { scenario -> - val annotation = createStampAnnotation() - - scenario.onActivity { - setupAnnotationsOnView(listOf(annotation)) - - // Touch at (300, 300) is outside the 100-200 bounds - val event = obtainMotionEvent(MotionEvent.ACTION_DOWN, 300f, 300f) - val consumed = selectionTouchHandler.handleTouch(annotationsView, event) - - assertThat(consumed).isFalse() - assertThat(testListener.lastSelectedAnnotation).isNull() - } - } - } - - @Test - fun handleTouchEvent_swipeGesture_hitDetected() { - ActivityScenario.launch(PdfViewTestActivity::class.java).use { scenario -> - val annotation = createStampAnnotation() - - scenario.onActivity { - setupAnnotationsOnView(listOf(annotation)) - - // Simulate ACTION_MOVE (Swipe) over the annotation - val event = obtainMotionEvent(MotionEvent.ACTION_MOVE, 150f, 150f) - val consumed = selectionTouchHandler.handleTouch(annotationsView, event) - - assertThat(consumed).isTrue() - assertThat(testListener.lastSelectedAnnotation).isEqualTo(annotation) - } - } - } - - @Test - fun handleTouchEvent_overlappingAnnotations_selectsTopMost() { - ActivityScenario.launch(PdfViewTestActivity::class.java).use { scenario -> - val bottomAnnotation = createStampAnnotation() - val topAnnotation = createStampAnnotation(10f) - - scenario.onActivity { - // The list order represents Z-order: index 0 is bottom, last index is top - setupAnnotationsOnView(listOf(bottomAnnotation, topAnnotation)) - - // Simulate Touch inside the overlapping bounds - val event = obtainMotionEvent(MotionEvent.ACTION_DOWN, 150f, 150f) - val consumed = selectionTouchHandler.handleTouch(annotationsView, event) - - assertThat(consumed).isTrue() - // Should return topAnnotation because findAnnotationAtPoint iterates in reverse - assertThat(testListener.lastSelectedAnnotation).isEqualTo(topAnnotation) - assertThat(testListener.lastSelectedAnnotation).isNotEqualTo(bottomAnnotation) - } - } - } - - @Test - fun handleTouchEvent_hitDetectedOnSecondPath_notifiesListener() { - ActivityScenario.launch(PdfViewTestActivity::class.java).use { scenario -> - val annotation = createStampAnnotation() - - scenario.onActivity { - setupAnnotationsOnView(listOf(annotation)) - - // Touch at (150, 190) is inside the second path (125, 180 to 175, 195) - val event = obtainMotionEvent(MotionEvent.ACTION_DOWN, 150f, 190f) - val consumed = selectionTouchHandler.handleTouch(annotationsView, event) - - assertThat(consumed).isTrue() - assertThat(testListener.lastSelectedAnnotation).isEqualTo(annotation) - } - } - } - - private fun setupAnnotationsOnView(annotations: List) { - val keyedAnnotations = - annotations.map { KeyedPdfAnnotation(key = UUID.randomUUID().toString(), it) } - val data = PageAnnotationsData(keyedAnnotations, Matrix()) - val sparseArray = SparseArray() - sparseArray.put(0, data) - annotationsView.annotations = sparseArray - } - - /** - * Creates a StampAnnotation with hard-coded bounds and paths for predictable testing. Overall - * Bounds: (100, 100, 200, 200) Path 1 (Square): (125, 125) to (175, 175) Path 2 (Rectangle): - * (125, 180) to (175, 195) - */ - private fun createStampAnnotation(offset: Float = 0f): StampAnnotation { - val bounds = RectF(100f + offset, 100f + offset, 200f + offset, 200f + offset) - - val path1 = - PathPdfObject( - Color.RED, - 0f, - listOf( - PathPdfObject.PathInput(125f, 125f), - PathPdfObject.PathInput(175f, 125f), - PathPdfObject.PathInput(175f, 175f), - PathPdfObject.PathInput(125f, 175f), - ), - ) - - val path2 = - PathPdfObject( - Color.BLUE, - 0f, - listOf( - PathPdfObject.PathInput(125f, 180f), - PathPdfObject.PathInput(175f, 180f), - PathPdfObject.PathInput(175f, 195f), - PathPdfObject.PathInput(125f, 195f), - ), - ) - - return StampAnnotation(0, bounds, listOf(path1, path2)) - } - - private fun obtainMotionEvent(action: Int, x: Float, y: Float): MotionEvent { - val now = SystemClock.uptimeMillis() - return MotionEvent.obtain(now, now, action, x, y, 0) - } - - class FakeAnnotationSelectedListener : OnAnnotationSelectedListener { - var lastSelectedAnnotation: PdfAnnotation? = null - - override fun onAnnotationSelected(keyedPdfAnnotation: KeyedPdfAnnotation) { - lastSelectedAnnotation = keyedPdfAnnotation.annotation - } - } - - private companion object { - const val PAGE_WIDTH = 500f - const val PAGE_HEIGHT = 500f - } -} diff --git a/pdf/pdf-viewer/src/androidTest/kotlin/androidx/pdf/annotation/AnnotationsLocatorTest.kt b/pdf/pdf-viewer/src/androidTest/kotlin/androidx/pdf/annotation/AnnotationsLocatorTest.kt new file mode 100644 index 0000000000000..bdb08de0417db --- /dev/null +++ b/pdf/pdf-viewer/src/androidTest/kotlin/androidx/pdf/annotation/AnnotationsLocatorTest.kt @@ -0,0 +1,167 @@ +/* + * Copyright 2025 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package androidx.pdf.annotation + +import android.content.Context +import android.graphics.Color +import android.graphics.Matrix +import android.graphics.RectF +import android.os.SystemClock +import android.util.SparseArray +import android.view.MotionEvent +import androidx.pdf.annotation.AnnotationsView.PageAnnotationsData +import androidx.pdf.annotation.models.PathPdfObject +import androidx.pdf.annotation.models.PdfAnnotation +import androidx.pdf.annotation.models.StampAnnotation +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.filters.SmallTest +import com.google.common.truth.Truth.assertThat +import java.util.UUID +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith + +@SmallTest +@RunWith(AndroidJUnit4::class) +class AnnotationsLocatorTest { + + private lateinit var annotationsLocator: AnnotationsLocator + private lateinit var context: Context + private lateinit var pageInfoProvider: FakePageInfoProvider + + @Before + fun setUp() { + context = ApplicationProvider.getApplicationContext() + pageInfoProvider = FakePageInfoProvider() + annotationsLocator = AnnotationsLocator(context, pageInfoProvider) + } + + @Test + fun findAnnotations_hitDetected_returnsListWithAnnotation() { + val annotationBounds = RectF(100f, 100f, 200f, 200f) + val annotation = createStampAnnotation(annotationBounds) + val annotationsData = createAnnotationsData(listOf(annotation)) + + // Simulate Touch inside bounds at (150, 150) + val event = obtainMotionEvent(MotionEvent.ACTION_DOWN, 150f, 150f) + + val results = annotationsLocator.findAnnotations(annotationsData, event) + + assertThat(results).isNotEmpty() + assertThat(results).hasSize(1) + assertThat(results[0].annotation).isEqualTo(annotation) + } + + @Test + fun findAnnotations_outsideBounds_returnsEmptyList() { + val annotationBounds = RectF(100f, 100f, 200f, 200f) + val annotation = createStampAnnotation(annotationBounds) + val annotationsData = createAnnotationsData(listOf(annotation)) + + // Simulate Touch outside bounds at (300, 300) + val event = obtainMotionEvent(MotionEvent.ACTION_DOWN, 300f, 300f) + + val results = annotationsLocator.findAnnotations(annotationsData, event) + + assertThat(results).isEmpty() + } + + @Test + fun findAnnotations_swipeGesture_hitDetected() { + val annotationBounds = RectF(100f, 100f, 200f, 200f) + val annotation = createStampAnnotation(annotationBounds) + val annotationsData = createAnnotationsData(listOf(annotation)) + + // Simulate ACTION_MOVE (Swipe) over the annotation + val event = obtainMotionEvent(MotionEvent.ACTION_MOVE, 150f, 150f) + + val results = annotationsLocator.findAnnotations(annotationsData, event) + + assertThat(results).hasSize(1) + assertThat(results[0].annotation).isEqualTo(annotation) + } + + @Test + fun findAnnotations_overlappingAnnotations_returnsCorrectOrder() { + // Both annotations share the same bounds, but one is logically "on top" (higher index) + val bounds = RectF(100f, 100f, 200f, 200f) + val bottomAnnotation = createStampAnnotation(bounds) // Z-Index 0 + val topAnnotation = createStampAnnotation(bounds) // Z-Index 1 + + // The list order represents Z-order: index 0 is bottom, last index is top + val annotationsData = createAnnotationsData(listOf(bottomAnnotation, topAnnotation)) + + // Simulate Touch inside the overlapping bounds + val event = obtainMotionEvent(MotionEvent.ACTION_DOWN, 150f, 150f) + + val results = annotationsLocator.findAnnotations(annotationsData, event) + + assertThat(results).hasSize(2) + // Verify reverse Z-order (Top-most element should be first in the returned list) + assertThat(results[0].annotation).isEqualTo(topAnnotation) + assertThat(results[1].annotation).isEqualTo(bottomAnnotation) + } + + @Test + fun findAnnotations_noAnnotationsOnPage_returnsEmptyList() { + val annotationsData = createAnnotationsData(emptyList()) + val event = obtainMotionEvent(MotionEvent.ACTION_DOWN, 150f, 150f) + + val results = annotationsLocator.findAnnotations(annotationsData, event) + + assertThat(results).isEmpty() + } + + // --- Helpers --- + + private fun createAnnotationsData( + annotations: List + ): SparseArray { + val keyedAnnotations = + annotations.map { KeyedPdfAnnotation(key = UUID.randomUUID().toString(), it) } + + // FakePageInfoProvider always returns pageNum = 0 + val data = PageAnnotationsData(keyedAnnotations, Matrix()) + val sparseArray = SparseArray() + sparseArray.put(0, data) + return sparseArray + } + + private fun createStampAnnotation(bounds: RectF): StampAnnotation { + val width = bounds.width() + val height = bounds.height() + + // Mock a simple rectangular path slightly inset from bounds + val pathInputs = + listOf( + PathPdfObject.PathInput(bounds.left + width / 4, bounds.top + height / 4), + PathPdfObject.PathInput(bounds.right - width / 4, bounds.top + height / 4), + PathPdfObject.PathInput(bounds.right - width / 4, bounds.bottom - height / 4), + PathPdfObject.PathInput(bounds.left + width / 4, bounds.bottom - height / 4), + PathPdfObject.PathInput(bounds.left + width / 4, bounds.top + height / 4), + ) + + val pathObject = PathPdfObject(Color.RED, 10f, pathInputs) + return StampAnnotation(0, bounds, listOf(pathObject)) + } + + private fun obtainMotionEvent(action: Int, x: Float, y: Float): MotionEvent { + val now = SystemClock.uptimeMillis() + return MotionEvent.obtain(now, now, action, x, y, 0) + } +} diff --git a/pdf/pdf-viewer/src/androidTest/kotlin/androidx/pdf/annotation/AnnotationsViewTest.kt b/pdf/pdf-viewer/src/androidTest/kotlin/androidx/pdf/annotation/AnnotationsViewTest.kt index 8e45c43d9646e..cc54b3c63c6c0 100644 --- a/pdf/pdf-viewer/src/androidTest/kotlin/androidx/pdf/annotation/AnnotationsViewTest.kt +++ b/pdf/pdf-viewer/src/androidTest/kotlin/androidx/pdf/annotation/AnnotationsViewTest.kt @@ -55,7 +55,7 @@ class AnnotationsViewTest { private lateinit var annotationsView: AnnotationsView private lateinit var fakePdfDocument: FakePdfDocument private lateinit var testHighlightListener: FakeInProgressTextHighlightsListener - private lateinit var testOnAnnotationSelectedListener: FakeOnAnnotationSelectedListener + private lateinit var testOnAnnotationSelectedListener: FakeOnAnnotationLocatedListener private val startIdlingResource = CountingIdlingResource(HIGHLIGHT_START_RESOURCE_NAME) private val finishIdlingResource = CountingIdlingResource(HIGHLIGHT_FINISH_RESOURCE_NAME) @@ -71,7 +71,7 @@ class AnnotationsViewTest { testHighlightListener = FakeInProgressTextHighlightsListener(startIdlingResource, finishIdlingResource) - testOnAnnotationSelectedListener = FakeOnAnnotationSelectedListener() + testOnAnnotationSelectedListener = FakeOnAnnotationLocatedListener() setupActivity() } @@ -188,7 +188,7 @@ class AnnotationsViewTest { ActivityScenario.launch(PdfViewTestActivity::class.java).use { scenario -> scenario.onActivity { annotationsView.interactionMode = AnnotationMode.Select() - annotationsView.addOnAnnotationSelectedListener(testOnAnnotationSelectedListener) + annotationsView.addOnAnnotationLocatedListener(testOnAnnotationSelectedListener) // Touch down on annotation free point. val event = obtainMotionEvent(10f, 10f, MotionEvent.ACTION_DOWN) @@ -206,7 +206,7 @@ class AnnotationsViewTest { ActivityScenario.launch(PdfViewTestActivity::class.java).use { scenario -> scenario.onActivity { annotationsView.interactionMode = AnnotationMode.Select() - annotationsView.addOnAnnotationSelectedListener(testOnAnnotationSelectedListener) + annotationsView.addOnAnnotationLocatedListener(testOnAnnotationSelectedListener) // Touch down on annotation point. val event = obtainMotionEvent(75f, 75f, MotionEvent.ACTION_DOWN) diff --git a/pdf/pdf-viewer/src/androidTest/kotlin/androidx/pdf/annotation/FakeOnAnnotationSelectedListener.kt b/pdf/pdf-viewer/src/androidTest/kotlin/androidx/pdf/annotation/FakeOnAnnotationLocatedListener.kt similarity index 81% rename from pdf/pdf-viewer/src/androidTest/kotlin/androidx/pdf/annotation/FakeOnAnnotationSelectedListener.kt rename to pdf/pdf-viewer/src/androidTest/kotlin/androidx/pdf/annotation/FakeOnAnnotationLocatedListener.kt index 15ed8e757f81e..3e252b0bd3cd9 100644 --- a/pdf/pdf-viewer/src/androidTest/kotlin/androidx/pdf/annotation/FakeOnAnnotationSelectedListener.kt +++ b/pdf/pdf-viewer/src/androidTest/kotlin/androidx/pdf/annotation/FakeOnAnnotationLocatedListener.kt @@ -16,11 +16,11 @@ package androidx.pdf.annotation -internal class FakeOnAnnotationSelectedListener : OnAnnotationSelectedListener { +internal class FakeOnAnnotationLocatedListener : OnAnnotationLocatedListener { var isHit: Boolean = false private set - override fun onAnnotationSelected(keyedPdfAnnotation: KeyedPdfAnnotation) { + override fun onAnnotationsLocated(locatedAnnotations: LocatedAnnotations) { isHit = true } } diff --git a/pdf/pdf-viewer/src/main/kotlin/androidx/pdf/annotation/AnnotationSelectionTouchHandler.kt b/pdf/pdf-viewer/src/main/kotlin/androidx/pdf/annotation/AnnotationsLocator.kt similarity index 67% rename from pdf/pdf-viewer/src/main/kotlin/androidx/pdf/annotation/AnnotationSelectionTouchHandler.kt rename to pdf/pdf-viewer/src/main/kotlin/androidx/pdf/annotation/AnnotationsLocator.kt index 02f104fbb111f..b8dcd14043d2b 100644 --- a/pdf/pdf-viewer/src/main/kotlin/androidx/pdf/annotation/AnnotationSelectionTouchHandler.kt +++ b/pdf/pdf-viewer/src/main/kotlin/androidx/pdf/annotation/AnnotationsLocator.kt @@ -16,11 +16,14 @@ package androidx.pdf.annotation +import android.content.Context import android.graphics.Path import android.graphics.RectF import android.graphics.Region +import android.util.SparseArray import android.view.MotionEvent import android.view.ViewConfiguration +import androidx.pdf.annotation.AnnotationsView.PageAnnotationsData import androidx.pdf.annotation.models.PathPdfObject import androidx.pdf.annotation.models.PdfAnnotation import androidx.pdf.annotation.models.StampAnnotation @@ -33,47 +36,37 @@ import kotlin.math.floor * This handler converts view coordinates to PDF page coordinates and checks for intersections with * annotations currently rendered by [AnnotationsView]. */ -internal class AnnotationSelectionTouchHandler() { - - private var onAnnotationSelectedListener: OnAnnotationSelectedListener? = null - - /** Registers a listener to be notified of annotation hit events. */ - fun setListener(listener: OnAnnotationSelectedListener) { - onAnnotationSelectedListener = listener - } +internal class AnnotationsLocator( + context: Context, + private val pageInfoProvider: PageInfoProvider, +) { + private val touchSlop = ViewConfiguration.get(context).scaledTouchSlop /** Handles the touch event to perform hit detection. */ - internal fun handleTouch(annotationsView: AnnotationsView, event: MotionEvent): Boolean { + internal fun findAnnotations( + annotations: SparseArray, + event: MotionEvent, + ): List { return when (event.actionMasked) { MotionEvent.ACTION_DOWN, MotionEvent.ACTION_MOVE -> { - val pageInfoProvider = annotationsView.pageInfoProvider ?: return false - val pageInfo = - pageInfoProvider.getPageInfoFromViewCoordinates(event.x, event.y) - ?: return false - - val selectedAnnotation = findAnnotationAtPoint(annotationsView, pageInfo, event) - - if (selectedAnnotation != null) { - onAnnotationSelectedListener?.onAnnotationSelected(selectedAnnotation) - return true - } - false + val pageInfo = pageInfoProvider.getPageInfoFromViewCoordinates(event.x, event.y) + pageInfo?.let { findAnnotationsAtPoint(it, annotations, event) } ?: emptyList() } - else -> false + + else -> emptyList() } } - /** Finds the top-most annotation at the given touch point using precise path intersection. */ - private fun findAnnotationAtPoint( - annotationsView: AnnotationsView, + /** + * Finds the list of annotations ordered by z-index (top -> bottom) at the given touch point + * using precise path intersection. + */ + private fun findAnnotationsAtPoint( pageInfo: PageInfoProvider.PageInfo, + annotations: SparseArray, event: MotionEvent, - ): KeyedPdfAnnotation? { - // Use the system's touch slop for a density-aware tolerance. - val touchSlop: Int = ViewConfiguration.get(annotationsView.context).scaledTouchSlop - - // Calculate the Touch Delta in PDF coordinates using touchSlop for tolerance. + ): List { val touchRectView = RectF( event.x - touchSlop, @@ -87,12 +80,13 @@ internal class AnnotationSelectionTouchHandler() { val touchRegion = touchRectPdf.toRegion() val keyedPdfAnnotations = - annotationsView.annotations.get(pageInfo.pageNum)?.keyedAnnotations ?: return null + annotations.get(pageInfo.pageNum)?.keyedAnnotations ?: return emptyList() - // Iterate in reverse Z-order to find the top-most annotation. - return keyedPdfAnnotations.asReversed().firstOrNull { keyedPdfAnnotation -> - isAnnotationHit(keyedPdfAnnotation.annotation, touchRegion, touchRectPdf) - } + return keyedPdfAnnotations + .filter { keyedPdfAnnotation -> + isAnnotationHit(keyedPdfAnnotation.annotation, touchRegion, touchRectPdf) + } + .asReversed() } /** diff --git a/pdf/pdf-viewer/src/main/kotlin/androidx/pdf/annotation/AnnotationsView.kt b/pdf/pdf-viewer/src/main/kotlin/androidx/pdf/annotation/AnnotationsView.kt index d199027d0740f..562efb611c99b 100644 --- a/pdf/pdf-viewer/src/main/kotlin/androidx/pdf/annotation/AnnotationsView.kt +++ b/pdf/pdf-viewer/src/main/kotlin/androidx/pdf/annotation/AnnotationsView.kt @@ -47,22 +47,13 @@ public class AnnotationsView constructor(context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0) : FrameLayout(context, attrs, defStyleAttr) { - private val onAnnotationSelectedListeners = mutableListOf() - private val annotationSelectionTouchHandler = - AnnotationSelectionTouchHandler().apply { - setListener( - object : OnAnnotationSelectedListener { - override fun onAnnotationSelected(keyedPdfAnnotation: KeyedPdfAnnotation) { - onAnnotationSelectedListeners.forEach { - it.onAnnotationSelected(keyedPdfAnnotation) - } - } - } - ) - } + private val onAnnotationLocatedListeners = mutableListOf() + /** The view for displaying in-progress annotations (e.g., wet highlights). */ private val inProgressHighlightsView: InProgressHighlightsView + private var annotationsLocator: AnnotationsLocator? = null + init { setWillNotDraw(false) @@ -109,6 +100,10 @@ constructor(context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0 set(value) { field = value inProgressHighlightsView.pageInfoProvider = value + + if (value != null) { + annotationsLocator = AnnotationsLocator(context, pageInfoProvider = value) + } } /** Adds a listener for highlight gesture events. */ @@ -117,9 +112,9 @@ constructor(context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0 } /** Adds a listener for annotation hit events. */ - public fun addOnAnnotationSelectedListener(listener: OnAnnotationSelectedListener) { - if (!onAnnotationSelectedListeners.contains(listener)) { - onAnnotationSelectedListeners.add(listener) + public fun addOnAnnotationLocatedListener(listener: OnAnnotationLocatedListener) { + if (!onAnnotationLocatedListeners.contains(listener)) { + onAnnotationLocatedListeners.add(listener) } } @@ -128,9 +123,9 @@ constructor(context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0 inProgressHighlightsView.removeInProgressTextHighlightsListener(listener) } - /** Removes a listener that was previously added via [addOnAnnotationSelectedListener]. */ - public fun removeOnAnnotationSelectedListener(listener: OnAnnotationSelectedListener) { - onAnnotationSelectedListeners.remove(listener) + /** Removes a listener that was previously added via [addOnAnnotationLocatedListener]. */ + public fun removeOnAnnotationLocatedListener(listener: OnAnnotationLocatedListener) { + onAnnotationLocatedListeners.remove(listener) } private var pdfObjectDrawerFactory: PdfObjectDrawerFactory = DefaultPdfObjectDrawerFactoryImpl @@ -164,7 +159,20 @@ constructor(context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0 override fun onTouchEvent(event: MotionEvent): Boolean { return when (interactionMode) { is AnnotationMode.Select -> { - annotationSelectionTouchHandler.handleTouch(this, event) + val localAnnotationsLocator = annotationsLocator + if (localAnnotationsLocator != null) { + val foundAnnotations = + localAnnotationsLocator.findAnnotations(annotations, event) + if (foundAnnotations.isNotEmpty()) { + onAnnotationLocatedListeners.forEach { + val event = + LocatedAnnotations(x = event.x, y = event.y, foundAnnotations) + it.onAnnotationsLocated(event) + } + return true + } + } + false } is AnnotationMode.Highlight -> { if (inProgressHighlightsView.visibility == VISIBLE) { @@ -218,6 +226,6 @@ constructor(context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0 /** Callback interface for annotation hit events. */ @RestrictTo(RestrictTo.Scope.LIBRARY) -public interface OnAnnotationSelectedListener { - public fun onAnnotationSelected(keyedPdfAnnotation: KeyedPdfAnnotation) +public interface OnAnnotationLocatedListener { + public fun onAnnotationsLocated(locatedAnnotations: LocatedAnnotations) } diff --git a/pdf/pdf-viewer/src/main/kotlin/androidx/pdf/annotation/LocatedAnnotations.kt b/pdf/pdf-viewer/src/main/kotlin/androidx/pdf/annotation/LocatedAnnotations.kt new file mode 100644 index 0000000000000..c5b420432ec80 --- /dev/null +++ b/pdf/pdf-viewer/src/main/kotlin/androidx/pdf/annotation/LocatedAnnotations.kt @@ -0,0 +1,54 @@ +/* + * Copyright 2026 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package androidx.pdf.annotation + +import androidx.annotation.RestrictTo + +/** + * Represents the result of a successful hit test on PDF annotations. + * + * @property x The x-coordinate of the touch event in view coordinates. + * @property y The y-coordinate of the touch event in view coordinates. + * @property annotations The list of [KeyedPdfAnnotation] objects found at the (x, y) location, + * typically ordered by visual stacking order (Z-index) (top-bottom). + */ +// TODO: Revisit the class parameters based on requirements. +@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) +public class LocatedAnnotations( + public val x: Float, + public val y: Float, + public val annotations: List, +) { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is LocatedAnnotations) return false + + if (x != other.x) return false + if (y != other.y) return false + + if (annotations != other.annotations) return false + + return true + } + + override fun hashCode(): Int { + var result = x.hashCode() + result = 31 * result + y.hashCode() + result = 31 * result + annotations.hashCode() + return result + } +} From c4484fa2b2d59b1c6f1b54aa8d7d8032e34897ff Mon Sep 17 00:00:00 2001 From: Aurimas Liutikas Date: Wed, 14 Jan 2026 16:48:17 -0800 Subject: [PATCH 3/3] Disable one more failing leanback test on API 36 Test: None Bug: 460508283 Change-Id: I13a633737b1ea1681034e18b3931e8570c08e7c6 --- .../androidx/leanback/app/BrowseSupportFragmentTest.java | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/leanback/leanback/src/androidTest/java/androidx/leanback/app/BrowseSupportFragmentTest.java b/leanback/leanback/src/androidTest/java/androidx/leanback/app/BrowseSupportFragmentTest.java index 41a12a55db76f..00e222acb1701 100644 --- a/leanback/leanback/src/androidTest/java/androidx/leanback/app/BrowseSupportFragmentTest.java +++ b/leanback/leanback/src/androidTest/java/androidx/leanback/app/BrowseSupportFragmentTest.java @@ -19,12 +19,10 @@ import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertNull; import static org.junit.Assert.assertTrue; -import static org.junit.Assume.assumeFalse; import android.app.Instrumentation; import android.content.Context; import android.content.Intent; -import android.os.Build; import android.os.Bundle; import android.os.SystemClock; import android.view.KeyEvent; @@ -50,6 +48,7 @@ import androidx.recyclerview.widget.RecyclerView; import androidx.test.ext.junit.runners.AndroidJUnit4; import androidx.test.filters.LargeTest; +import androidx.test.filters.SdkSuppress; import androidx.test.platform.app.InstrumentationRegistry; import androidx.test.rule.ActivityTestRule; @@ -123,9 +122,9 @@ public boolean canProceed() { }); } + @SdkSuppress(maxSdkVersion = 35) // b/460508283 @Test public void testTouchMode() throws Throwable { - assumeFalse("Test fails on cuttlefish b/460508283", Build.MODEL.contains("Cuttlefish")); Intent intent = new Intent(); intent.putExtra(BrowseFragmentTestActivity.EXTRA_ADD_TO_BACKSTACK , true); intent.putExtra(BrowseFragmentTestActivity.EXTRA_LOAD_DATA_DELAY , 0L);