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, 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); 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 + } +}