Merge "Implements basic text selection" into androidx-main
diff --git a/pdf/pdf-viewer/src/main/kotlin/androidx/pdf/view/PageLayoutManager.kt b/pdf/pdf-viewer/src/main/kotlin/androidx/pdf/view/PageLayoutManager.kt
index 4af7810..1a468ec 100644
--- a/pdf/pdf-viewer/src/main/kotlin/androidx/pdf/view/PageLayoutManager.kt
+++ b/pdf/pdf-viewer/src/main/kotlin/androidx/pdf/view/PageLayoutManager.kt
@@ -104,44 +104,28 @@
     }
 
     /**
-     * Returns the page number containing the specified PDF coordinates within the given viewport.
-     * If no page contains the coordinates, returns [INVALID_ID].
+     * Returns the [PdfPoint] that exists at [contentCoordinates], or null if no page content is
+     * laid out at [contentCoordinates].
      *
-     * @param pdfCoordinates The PDF coordinates to check.
-     * @param viewport The visible area of the PDF.
-     * @return The page number or [INVALID_ID].
+     * @param contentCoordinates the content coordinates to check (View coordinates that are scaled
+     *   up or down by the current zoom level)
+     * @param viewport the current viewport in content coordinates
      */
-    fun getPageNumberAt(pdfCoordinates: PointF, viewport: Rect): Int {
+    fun getPdfPointAt(contentCoordinates: PointF, viewport: Rect): PdfPoint? {
         val visiblePages = visiblePages.value
         for (pageIndex in visiblePages.lower..visiblePages.upper) {
             val pageBounds = paginationModel.getPageLocation(pageIndex, viewport)
-            if (RectF(pageBounds).contains(pdfCoordinates.x, pdfCoordinates.y)) {
-                return pageIndex
+            if (RectF(pageBounds).contains(contentCoordinates.x, contentCoordinates.y)) {
+                return PdfPoint(
+                    pageIndex,
+                    PointF(
+                        contentCoordinates.x - pageBounds.left,
+                        contentCoordinates.y - pageBounds.top,
+                    )
+                )
             }
         }
-        return INVALID_ID
-    }
-
-    /**
-     * Converts tap coordinates (relative to the viewport) to content coordinates relative to the
-     * specified page.
-     *
-     * @param pageNum The 0-indexed page number.
-     * @param viewport The current viewport's dimensions.
-     * @param tapCoordinates The tap coordinates relative to the visible pages.
-     * @return The coordinates relative to the clicked page.
-     */
-    fun getPageCoordinatesRelativeToTappedPage(
-        pageNum: Int,
-        viewport: Rect,
-        tapCoordinates: PointF
-    ): PointF {
-        val pageLocation = getPageLocation(pageNum, viewport)
-
-        val contentX = tapCoordinates.x - pageLocation.left
-        val contentY = tapCoordinates.y - pageLocation.top
-
-        return PointF(contentX, contentY)
+        return null
     }
 
     /**
diff --git a/pdf/pdf-viewer/src/main/kotlin/androidx/pdf/view/PdfView.kt b/pdf/pdf-viewer/src/main/kotlin/androidx/pdf/view/PdfView.kt
index d895fe4..d734d3c 100644
--- a/pdf/pdf-viewer/src/main/kotlin/androidx/pdf/view/PdfView.kt
+++ b/pdf/pdf-viewer/src/main/kotlin/androidx/pdf/view/PdfView.kt
@@ -141,11 +141,17 @@
     /** The listener that is notified when a link in the PDF is clicked. */
     public var linkClickListener: LinkClickListener? = null
 
+    /** The currently selected PDF content, as [Selection] */
+    public val currentSelection: Selection?
+        get() {
+            return selectionStateManager?.selectionModel?.selection
+        }
+
     /**
      * The [CoroutineScope] used to make suspending calls to [PdfDocument]. The size of the fixed
      * thread pool is arbitrary and subject to tuning.
      */
-    internal val backgroundScope: CoroutineScope =
+    private val backgroundScope: CoroutineScope =
         CoroutineScope(Executors.newFixedThreadPool(5).asCoroutineDispatcher() + SupervisorJob())
 
     private var pageLayoutManager: PageLayoutManager? = null
@@ -153,6 +159,7 @@
     private var visiblePagesCollector: Job? = null
     private var dimensionsCollector: Job? = null
     private var invalidationCollector: Job? = null
+    private var selectionStateCollector: Job? = null
 
     private var deferredScrollPage: Int? = null
     private var deferredScrollPosition: PdfPoint? = null
@@ -186,6 +193,9 @@
             field = value
         }
 
+    private var selectionStateManager: SelectionStateManager? = null
+    private val selectionRenderer = SelectionRenderer(context)
+
     /**
      * Scrolls to the 0-indexed [pageNum], optionally animating the scroll
      *
@@ -264,6 +274,11 @@
         scrollTo(x.toInt(), y.toInt())
     }
 
+    /** Clears the current selection, if one exists. No-op if there is no current [Selection] */
+    public fun clearSelection() {
+        selectionStateManager?.clearSelection()
+    }
+
     private fun gotoPoint(position: PdfPoint) {
         checkMainThread()
         val localPageLayoutManager =
@@ -315,12 +330,19 @@
         val localPaginationManager = pageLayoutManager ?: return
         canvas.save()
         canvas.scale(zoom, zoom)
+        val selectionModel = selectionStateManager?.selectionModel
         for (i in visiblePages.lower..visiblePages.upper) {
-            pageManager?.drawPage(
-                i,
-                canvas,
-                localPaginationManager.getPageLocation(i, getVisibleAreaInContentCoords())
-            )
+            val pageLoc = localPaginationManager.getPageLocation(i, getVisibleAreaInContentCoords())
+            pageManager?.drawPage(i, canvas, pageLoc)
+            selectionModel?.let {
+                selectionRenderer.drawSelectionOnPage(
+                    model = it,
+                    pageNum = i,
+                    canvas,
+                    pageLoc,
+                    zoom
+                )
+            }
         }
         canvas.restore()
     }
@@ -523,6 +545,10 @@
         zoomToRestore = null
     }
 
+    /**
+     * Launches a tree of coroutines to collect data from helper classes while we're attached to a
+     * visible window
+     */
     private fun startCollectingData() {
         val mainScope =
             CoroutineScope(HandlerCompat.createAsync(handler.looper).asCoroutineDispatcher())
@@ -562,12 +588,22 @@
                     }
                 }
         }
+        selectionStateManager?.let { manager ->
+            val selectionToJoin = selectionStateCollector?.apply { cancel() }
+            selectionStateCollector =
+                mainScope.launch(start = CoroutineStart.UNDISPATCHED) {
+                    // Prevent 2 copies from running concurrently
+                    selectionToJoin?.join()
+                    manager.invalidationSignalFlow.collect { invalidate() }
+                }
+        }
     }
 
     private fun stopCollectingData() {
         dimensionsCollector?.cancel()
         visiblePagesCollector?.cancel()
         invalidationCollector?.cancel()
+        selectionStateCollector?.cancel()
     }
 
     /** Start using the [PdfDocument] to present PDF content */
@@ -590,6 +626,11 @@
                 Point(maxBitmapDimensionPx, maxBitmapDimensionPx),
                 isTouchExplorationEnabled
             )
+        selectionStateManager =
+            SelectionStateManager(
+                localPdfDocument,
+                backgroundScope,
+            )
         // We'll either create our layout manager from restored state, or instantiate a new one
         if (!maybeRestoreState()) {
             pageLayoutManager =
@@ -784,6 +825,7 @@
          */
         private val linearScaleSpanMultiplier: Float =
             2f / maxOf(resources.displayMetrics.heightPixels, resources.displayMetrics.widthPixels)
+
         /** The maximum scroll distance used to determine if the direction is vertical. */
         private val maxScrollWindow =
             (resources.displayMetrics.density * MAX_SCROLL_WINDOW_DP).toInt()
@@ -881,6 +923,18 @@
             return true
         }
 
+        override fun onLongPress(e: MotionEvent) {
+            super.onLongPress(e)
+            val pageLayoutManager = pageLayoutManager ?: return super.onLongPress(e)
+            val touchPoint =
+                pageLayoutManager.getPdfPointAt(
+                    PointF(toContentX(e.x), toContentY(e.y)),
+                    getVisibleAreaInContentCoords()
+                ) ?: return super.onLongPress(e)
+
+            selectionStateManager?.maybeSelectWordAtPoint(touchPoint)
+        }
+
         override fun onDoubleTap(e: MotionEvent): Boolean {
 
             val currentZoom = zoom
@@ -932,27 +986,17 @@
         }
 
         override fun onSingleTapConfirmed(e: MotionEvent): Boolean {
-            val contentX = toContentX(e.x)
-            val contentY = toContentY(e.y)
-
+            selectionStateManager?.clearSelection()
             val pageLayoutManager = pageLayoutManager ?: return super.onSingleTapConfirmed(e)
-            val pageNum =
-                pageLayoutManager.getPageNumberAt(
-                    PointF(contentX, contentY),
+            val touchPoint =
+                pageLayoutManager.getPdfPointAt(
+                    PointF(toContentX(e.x), toContentY(e.y)),
                     getVisibleAreaInContentCoords()
-                )
-            if (pageNum == INVALID_ID) return super.onSingleTapConfirmed(e)
+                ) ?: return super.onSingleTapConfirmed(e)
 
-            val pdfCoordinates =
-                pageLayoutManager.getPageCoordinatesRelativeToTappedPage(
-                    pageNum,
-                    getVisibleAreaInContentCoords(),
-                    PointF(contentX, contentY)
-                )
-
-            pageManager?.getLinkAtTapPoint(PdfPoint(pageNum, pdfCoordinates))?.let { links ->
-                if (handleGotoLinks(links, pdfCoordinates)) return true
-                if (handleExternalLinks(links, pdfCoordinates)) return true
+            pageManager?.getLinkAtTapPoint(touchPoint)?.let { links ->
+                if (handleGotoLinks(links, touchPoint.pagePoint)) return true
+                if (handleExternalLinks(links, touchPoint.pagePoint)) return true
             }
             return super.onSingleTapConfirmed(e)
         }
@@ -972,6 +1016,7 @@
                                     gotoLink.destination.yCoordinate
                                 )
                         )
+
                     scrollToPosition(destination)
                     return true
                 }
@@ -1005,8 +1050,10 @@
 
         /** The ratio of vertical to horizontal scroll that is assumed to be vertical only */
         private const val SCROLL_CORRECTION_RATIO = 1.5f
+
         /** The maximum scroll distance used to determine if the direction is vertical */
         private const val MAX_SCROLL_WINDOW_DP = 70
+
         /** The smallest scroll distance that can switch mode to "free scrolling" */
         private const val MIN_SCROLL_TO_SWITCH_DP = 30
 
diff --git a/pdf/pdf-viewer/src/main/kotlin/androidx/pdf/view/SelectionModel.kt b/pdf/pdf-viewer/src/main/kotlin/androidx/pdf/view/SelectionModel.kt
new file mode 100644
index 0000000..a52a63e
--- /dev/null
+++ b/pdf/pdf-viewer/src/main/kotlin/androidx/pdf/view/SelectionModel.kt
@@ -0,0 +1,76 @@
+/*
+ * Copyright 2024 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.view
+
+import android.graphics.PointF
+import androidx.annotation.VisibleForTesting
+import androidx.pdf.PdfDocument
+import androidx.pdf.content.PageSelection
+
+/** Value class containing all data necessary to display UI related to content selection */
+internal class SelectionModel
+@VisibleForTesting
+internal constructor(
+    val selection: Selection,
+    val startBoundary: UiSelectionBoundary,
+    val endBoundary: UiSelectionBoundary
+) {
+    companion object {
+        /** Produces a [SelectionModel] from a single [PageSelection] on a single page */
+        fun fromSinglePageSelection(pageSelection: PageSelection): SelectionModel {
+            val startPoint =
+                requireNotNull(pageSelection.start.point) { "PageSelection is missing start point" }
+            val stopPoint =
+                requireNotNull(pageSelection.stop.point) { "PageSelection is missing end point" }
+            return SelectionModel(
+                pageSelection.toViewSelection(),
+                UiSelectionBoundary(
+                    PdfPoint(
+                        pageSelection.page,
+                        PointF(startPoint.x.toFloat(), startPoint.y.toFloat())
+                    ),
+                    pageSelection.start.isRtl
+                ),
+                UiSelectionBoundary(
+                    PdfPoint(
+                        pageSelection.page,
+                        PointF(stopPoint.x.toFloat(), stopPoint.y.toFloat())
+                    ),
+                    pageSelection.stop.isRtl
+                ),
+            )
+        }
+
+        /**
+         * Returns a [Selection] as exposed in the [PdfView] API from a [PageSelection] as produced
+         * by the [PdfDocument] API
+         */
+        private fun PageSelection.toViewSelection(): Selection {
+            val flattenedBounds =
+                this.selectedTextContents.map { it.bounds }.flatten().map { PdfRect(this.page, it) }
+            val concatenatedText = this.selectedTextContents.joinToString(" ") { it.text }
+            return TextSelection(concatenatedText, flattenedBounds)
+        }
+    }
+}
+
+/**
+ * Represents a selection boundary that includes the page on which it exists and the point at which
+ * it exists (as [PdfPoint]), as well as the direction of the selection ([isRtl] is true if the
+ * selection was made in a right-to-left direction).
+ */
+internal class UiSelectionBoundary(val location: PdfPoint, val isRtl: Boolean)
diff --git a/pdf/pdf-viewer/src/main/kotlin/androidx/pdf/view/SelectionRenderer.kt b/pdf/pdf-viewer/src/main/kotlin/androidx/pdf/view/SelectionRenderer.kt
new file mode 100644
index 0000000..fd8cb32
--- /dev/null
+++ b/pdf/pdf-viewer/src/main/kotlin/androidx/pdf/view/SelectionRenderer.kt
@@ -0,0 +1,143 @@
+/*
+ * Copyright 2024 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.view
+
+import android.content.Context
+import android.graphics.Canvas
+import android.graphics.Paint
+import android.graphics.Paint.Style
+import android.graphics.PointF
+import android.graphics.PorterDuff.Mode
+import android.graphics.PorterDuffColorFilter
+import android.graphics.PorterDuffXfermode
+import android.graphics.Rect
+import android.graphics.RectF
+import android.graphics.drawable.Drawable
+import androidx.annotation.VisibleForTesting
+import androidx.pdf.R
+import kotlin.math.roundToInt
+
+/**
+ * Draws content-selection related UI to a [Canvas]. UI state required for drawing, including the
+ * [Canvas], and selection state are provided externally; this class is stateless.
+ */
+internal class SelectionRenderer(
+    private val context: Context,
+    private val rightHandleFactory: () -> Drawable = {
+        requireNotNull(context.getDrawable(R.drawable.selection_drag_handle_right)).also {
+            it.colorFilter =
+                PorterDuffColorFilter(
+                    context.getColor(R.color.pdf_viewer_selection_handles),
+                    Mode.SRC_ATOP
+                )
+        }
+    },
+    private val leftHandleFactory: () -> Drawable = {
+        requireNotNull(context.getDrawable(R.drawable.selection_drag_handle_left)).also {
+            it.colorFilter =
+                PorterDuffColorFilter(
+                    context.getColor(R.color.pdf_viewer_selection_handles),
+                    Mode.SRC_ATOP
+                )
+        }
+    }
+) {
+    private val rightHandle: Drawable by lazy { rightHandleFactory() }
+
+    private val leftHandle: Drawable by lazy { leftHandleFactory() }
+
+    fun drawSelectionOnPage(
+        model: SelectionModel,
+        pageNum: Int,
+        canvas: Canvas,
+        locationInView: Rect,
+        currentZoom: Float
+    ) {
+        model.startBoundary.let {
+            val startLoc = it.location
+            if (startLoc.pageNum == pageNum) {
+                val pointInView =
+                    PointF(
+                        locationInView.left + startLoc.pagePoint.x,
+                        locationInView.top + startLoc.pagePoint.y
+                    )
+                drawHandleAtPosition(canvas, pointInView, isRight = false xor it.isRtl, currentZoom)
+            }
+        }
+
+        model.endBoundary.let {
+            val endLoc = it.location
+            if (endLoc.pageNum == pageNum) {
+                val pointInView =
+                    PointF(
+                        locationInView.left + endLoc.pagePoint.x,
+                        locationInView.top + endLoc.pagePoint.y
+                    )
+                drawHandleAtPosition(canvas, pointInView, isRight = true xor it.isRtl, currentZoom)
+            }
+        }
+
+        model.selection.bounds
+            .filter { it.pageNum == pageNum }
+            .forEach { drawBoundsOnPage(canvas, it, locationInView) }
+    }
+
+    private fun drawHandleAtPosition(
+        canvas: Canvas,
+        pointInView: PointF,
+        isRight: Boolean,
+        currentZoom: Float
+    ) {
+        // The sharp point of the handle is found at a particular point in the image - (25%, 10%)
+        // for the right handle, and (75%, 10%) for a left handle. We apply these as negative
+        // margins so that the handle's point is at the point specified
+        val relativePointLocX = if (isRight) -0.25f else -0.75f
+        val relativePointLocY = -0.10f
+        val drawable = if (isRight) rightHandle else leftHandle
+        // Don't draw ridiculously large selection handles at low zoom levels
+        val scale = minOf(1 / currentZoom, 0.5f)
+        val left = pointInView.x + relativePointLocX * drawable.intrinsicWidth * scale
+        val top = pointInView.y + relativePointLocY * drawable.intrinsicHeight * scale
+        drawable.setBounds(
+            left.roundToInt(),
+            top.roundToInt(),
+            (left + drawable.intrinsicWidth * scale).roundToInt(),
+            (top + drawable.intrinsicHeight * scale).roundToInt()
+        )
+        drawable.draw(canvas)
+    }
+
+    private fun drawBoundsOnPage(canvas: Canvas, bounds: PdfRect, pageLocationInView: Rect) {
+        val boundsRect = RectF(bounds.pageRect)
+        boundsRect.offset(pageLocationInView.left.toFloat(), pageLocationInView.top.toFloat())
+        canvas.drawRect(boundsRect, BOUNDS_PAINT)
+    }
+}
+
+/**
+ * Note the use of [Mode.MULTIPLY], which means this paint will darken the light areas of the
+ * destination, but leave dark areas unchanged, like a fluorescent highlighter
+ */
+@VisibleForTesting
+internal val BOUNDS_PAINT =
+    Paint().apply {
+        style = Style.FILL
+        xfermode = PorterDuffXfermode(Mode.MULTIPLY)
+        setARGB(255, 160, 215, 255)
+        isAntiAlias = true
+        isDither = true
+    }
diff --git a/pdf/pdf-viewer/src/main/kotlin/androidx/pdf/view/SelectionStateManager.kt b/pdf/pdf-viewer/src/main/kotlin/androidx/pdf/view/SelectionStateManager.kt
new file mode 100644
index 0000000..c4410d7
--- /dev/null
+++ b/pdf/pdf-viewer/src/main/kotlin/androidx/pdf/view/SelectionStateManager.kt
@@ -0,0 +1,100 @@
+/*
+ * Copyright 2024 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.view
+
+import androidx.pdf.PdfDocument
+import androidx.pdf.content.PageSelection
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.cancelAndJoin
+import kotlinx.coroutines.flow.MutableSharedFlow
+import kotlinx.coroutines.flow.SharedFlow
+import kotlinx.coroutines.launch
+
+/** Owns and updates all mutable state related to content selection in [PdfView] */
+internal class SelectionStateManager(
+    private val pdfDocument: PdfDocument,
+    private val backgroundScope: CoroutineScope,
+) {
+    /** The current [Selection] */
+    var selectionModel: SelectionModel? = null
+        private set
+
+    /**
+     * Replay at least 1 value in case of an invalidation signal issued while [PdfView] is not
+     * collecting
+     */
+    private val _invalidationSignalFlow = MutableSharedFlow<Unit>(replay = 1)
+
+    /**
+     * This [SharedFlow] serves as an event bus of sorts to signal our host [PdfView] to invalidate
+     * itself in a decoupled way.
+     */
+    val invalidationSignalFlow: SharedFlow<Unit>
+        get() = _invalidationSignalFlow
+
+    private var setSelectionJob: Job? = null
+
+    /** Asynchronously attempts to select the nearest block of text to [pdfPoint] */
+    fun maybeSelectWordAtPoint(pdfPoint: PdfPoint) {
+        updateSelectionAsync(pdfPoint, pdfPoint)
+    }
+
+    private fun updateSelectionAsync(start: PdfPoint, end: PdfPoint) {
+        val prevJob = setSelectionJob
+        setSelectionJob =
+            backgroundScope
+                .launch {
+                    prevJob?.cancelAndJoin()
+                    val newSelection =
+                        pdfDocument.getSelectionBounds(
+                            start.pageNum,
+                            start.pagePoint,
+                            end.pagePoint
+                        )
+                    if (newSelection != null && newSelection.hasBounds) {
+                        selectionModel = SelectionModel.fromSinglePageSelection(newSelection)
+                        _invalidationSignalFlow.emit(Unit)
+                    }
+                }
+                .also { it.invokeOnCompletion { setSelectionJob = null } }
+    }
+
+    /** Resets all state of this manager */
+    fun clearSelection() {
+        setSelectionJob?.cancel()
+        setSelectionJob = null
+        if (selectionModel != null) _invalidationSignalFlow.tryEmit(Unit)
+        selectionModel = null
+    }
+
+    /**
+     * Returns true if this [PageSelection] has selected content with bounds, and if its start and
+     * end boundaries include their location. Any selection without this information cannot be
+     * displayed in the UI, and we expect this information to be present.
+     *
+     * [androidx.pdf.content.SelectionBoundary] is overloaded as both an input to selection and an
+     * output from it, and here we are interacting with it as an output. In the output case, it
+     * should always specify its [androidx.pdf.content.SelectionBoundary.point]
+     */
+    private val PageSelection.hasBounds: Boolean
+        get() {
+            return this.selectedTextContents.any { it.bounds.isNotEmpty() } &&
+                this.start.point != null &&
+                this.stop.point != null
+        }
+}
diff --git a/pdf/pdf-viewer/src/main/kotlin/androidx/pdf/view/models.kt b/pdf/pdf-viewer/src/main/kotlin/androidx/pdf/view/models.kt
index e5e0f71..c0f01b2 100644
--- a/pdf/pdf-viewer/src/main/kotlin/androidx/pdf/view/models.kt
+++ b/pdf/pdf-viewer/src/main/kotlin/androidx/pdf/view/models.kt
@@ -108,3 +108,39 @@
         return "Highlight: area $area color $color"
     }
 }
+
+@RestrictTo(RestrictTo.Scope.LIBRARY)
+/** Represents PDF content that has been selected */
+public interface Selection {
+    /**
+     * The [PdfRect] bounds of this selection. May contain multiple [PdfRect] if this selection
+     * spans multiple discrete areas within the PDF. Consider for example any selection spanning
+     * multiple pages, or a text selection spanning multiple lines on the same page.
+     */
+    public val bounds: List<PdfRect>
+}
+
+/** Represents text content that has been selected */
+@RestrictTo(RestrictTo.Scope.LIBRARY)
+public class TextSelection(public val text: String, override val bounds: List<PdfRect>) :
+    Selection {
+    override fun equals(other: Any?): Boolean {
+        if (this === other) return true
+        if (other == null || other !is TextSelection) return false
+
+        if (other.text != this.text) return false
+        if (other.bounds != this.bounds) return false
+
+        return true
+    }
+
+    override fun hashCode(): Int {
+        var result = text.hashCode()
+        result = 31 * result + bounds.hashCode()
+        return result
+    }
+
+    override fun toString(): String {
+        return "TextSelection: text $text bounds $bounds"
+    }
+}
diff --git a/pdf/pdf-viewer/src/test/kotlin/androidx/pdf/view/SelectionRendererTest.kt b/pdf/pdf-viewer/src/test/kotlin/androidx/pdf/view/SelectionRendererTest.kt
new file mode 100644
index 0000000..4e0c1b5
--- /dev/null
+++ b/pdf/pdf-viewer/src/test/kotlin/androidx/pdf/view/SelectionRendererTest.kt
@@ -0,0 +1,229 @@
+/*
+ * 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.view
+
+import android.graphics.Canvas
+import android.graphics.ColorFilter
+import android.graphics.PixelFormat
+import android.graphics.Point
+import android.graphics.PointF
+import android.graphics.Rect
+import android.graphics.RectF
+import android.graphics.drawable.Drawable
+import androidx.test.core.app.ApplicationProvider
+import com.google.common.truth.Truth.assertThat
+import kotlin.math.roundToInt
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mockito.kotlin.eq
+import org.mockito.kotlin.spy
+import org.mockito.kotlin.verify
+import org.robolectric.RobolectricTestRunner
+
+@RunWith(RobolectricTestRunner::class)
+class SelectionRendererTest {
+    private lateinit var leftHandle: RecordingDrawable
+    private lateinit var rightHandle: RecordingDrawable
+    private lateinit var renderer: SelectionRenderer
+
+    private val canvasSpy = spy(Canvas())
+
+    @Before
+    fun setup() {
+        leftHandle = RecordingDrawable()
+        rightHandle = RecordingDrawable()
+        renderer =
+            SelectionRenderer(
+                ApplicationProvider.getApplicationContext(),
+                { leftHandle },
+                { rightHandle },
+            )
+    }
+
+    @Test
+    fun drawSelectionOnPage_rtl() {
+        val textSelection =
+            TextSelection(
+                "This is the text that's selected",
+                listOf(
+                    PdfRect(pageNum = 0, RectF(150F, 150F, 190F, 160F)),
+                    PdfRect(pageNum = 0, RectF(10F, 170F, 50F, 180F)),
+                )
+            )
+        val startBoundary =
+            UiSelectionBoundary(PdfPoint(pageNum = 0, PointF(150F, 160F)), isRtl = true)
+        val endBoundary =
+            UiSelectionBoundary(PdfPoint(pageNum = 0, PointF(50F, 180F)), isRtl = true)
+        val selection = SelectionModel(textSelection, startBoundary, endBoundary)
+        val locationInView = Rect(30, 50, 230, 250)
+        val currentZoom = 2F
+
+        renderer.drawSelectionOnPage(selection, pageNum = 0, canvasSpy, locationInView, currentZoom)
+
+        // Handle's location in page, adjusted for page's location in View, adjusted in the way we
+        // expect to position the "sharp point" of the handle, adjusted for the current zoom
+        val startLeftAdjusted =
+            startBoundary.location.pagePoint.x +
+                locationInView.left +
+                -0.25F * HANDLE_SIZE.x * 1 / currentZoom
+        val startTopAdjusted =
+            startBoundary.location.pagePoint.y +
+                locationInView.top +
+                -0.10F * HANDLE_SIZE.y * 1 / currentZoom
+        assertThat(leftHandle.drawingBounds)
+            .isEqualTo(
+                Rect(
+                    startLeftAdjusted.roundToInt(),
+                    startTopAdjusted.roundToInt(),
+                    (startLeftAdjusted + HANDLE_SIZE.x * 1 / currentZoom).roundToInt(),
+                    (startTopAdjusted + HANDLE_SIZE.y * 1 / currentZoom).roundToInt()
+                )
+            )
+
+        // Handle's location in page, adjusted for page's location in View, adjusted in the way we
+        // expect to position the "sharp point" of the handle, adjusted for the current zoom
+        val endLeftAdjusted =
+            endBoundary.location.pagePoint.x +
+                locationInView.left +
+                -0.75F * HANDLE_SIZE.x * 1 / currentZoom
+        val endTopAdjusted =
+            endBoundary.location.pagePoint.y +
+                locationInView.top +
+                -0.10F * HANDLE_SIZE.y * 1 / currentZoom
+        assertThat(rightHandle.drawingBounds)
+            .isEqualTo(
+                Rect(
+                    endLeftAdjusted.roundToInt(),
+                    endTopAdjusted.roundToInt(),
+                    (endLeftAdjusted + HANDLE_SIZE.x * 1 / currentZoom).roundToInt(),
+                    (endTopAdjusted + HANDLE_SIZE.y * 1 / currentZoom).roundToInt()
+                )
+            )
+
+        for (bound in textSelection.bounds.map { it.pageRect }) {
+            val expectedBounds =
+                RectF(bound).apply {
+                    offset(locationInView.left.toFloat(), locationInView.top.toFloat())
+                }
+            verify(canvasSpy).drawRect(eq(expectedBounds), eq(BOUNDS_PAINT))
+        }
+    }
+
+    @Test
+    fun drawSelectionOnPage_ltr() {
+        val textSelection =
+            TextSelection(
+                "This is the text that's selected",
+                listOf(
+                    PdfRect(pageNum = 0, RectF(150F, 150F, 190F, 160F)),
+                    PdfRect(pageNum = 0, RectF(10F, 170F, 50F, 180F)),
+                )
+            )
+        val startBoundary =
+            UiSelectionBoundary(PdfPoint(pageNum = 0, PointF(150F, 160F)), isRtl = false)
+        val endBoundary =
+            UiSelectionBoundary(PdfPoint(pageNum = 0, PointF(50F, 180F)), isRtl = false)
+        val selection = SelectionModel(textSelection, startBoundary, endBoundary)
+        val locationInView = Rect(30, 50, 230, 250)
+        val currentZoom = 2F
+
+        renderer.drawSelectionOnPage(selection, pageNum = 0, canvasSpy, locationInView, currentZoom)
+
+        // Handle's location in page, adjusted for page's location in View, adjusted in the way we
+        // expect to position the "sharp point" of the handle, adjusted for the current zoom
+        val startLeftAdjusted =
+            startBoundary.location.pagePoint.x +
+                locationInView.left +
+                -0.75F * HANDLE_SIZE.x * 1 / currentZoom
+        val startTopAdjusted =
+            startBoundary.location.pagePoint.y +
+                locationInView.top +
+                -0.10F * HANDLE_SIZE.y * 1 / currentZoom
+        assertThat(rightHandle.drawingBounds)
+            .isEqualTo(
+                Rect(
+                    startLeftAdjusted.roundToInt(),
+                    startTopAdjusted.roundToInt(),
+                    (startLeftAdjusted + HANDLE_SIZE.x * 1 / currentZoom).roundToInt(),
+                    (startTopAdjusted + HANDLE_SIZE.y * 1 / currentZoom).roundToInt()
+                )
+            )
+
+        // Handle's location in page, adjusted for page's location in View, adjusted in the way we
+        // expect to position the "sharp point" of the handle, adjusted for the current zoom
+        val endLeftAdjusted =
+            endBoundary.location.pagePoint.x +
+                locationInView.left +
+                -0.25F * HANDLE_SIZE.x * 1 / currentZoom
+        val endTopAdjusted =
+            endBoundary.location.pagePoint.y +
+                locationInView.top +
+                -0.10F * HANDLE_SIZE.y * 1 / currentZoom
+        assertThat(leftHandle.drawingBounds)
+            .isEqualTo(
+                Rect(
+                    endLeftAdjusted.roundToInt(),
+                    endTopAdjusted.roundToInt(),
+                    (endLeftAdjusted + HANDLE_SIZE.x * 1 / currentZoom).roundToInt(),
+                    (endTopAdjusted + HANDLE_SIZE.y * 1 / currentZoom).roundToInt()
+                )
+            )
+
+        for (bound in textSelection.bounds.map { it.pageRect }) {
+            val expectedBounds =
+                RectF(bound).apply {
+                    offset(locationInView.left.toFloat(), locationInView.top.toFloat())
+                }
+            verify(canvasSpy).drawRect(eq(expectedBounds), eq(BOUNDS_PAINT))
+        }
+    }
+}
+
+private val HANDLE_SIZE = Point(48, 48)
+
+/** Simple fake [Drawable] that records its bounds at drawing time */
+private class RecordingDrawable : Drawable() {
+    var drawingBounds: Rect = Rect(-1, -1, -1, -1)
+
+    override fun draw(canvas: Canvas) {
+        copyBounds(drawingBounds)
+    }
+
+    override fun setAlpha(alpha: Int) {
+        // No-op, fake
+    }
+
+    override fun setColorFilter(colorFilter: ColorFilter?) {
+        // No-op, fake
+    }
+
+    override fun getIntrinsicWidth(): Int {
+        return HANDLE_SIZE.x
+    }
+
+    override fun getIntrinsicHeight(): Int {
+        return HANDLE_SIZE.y
+    }
+
+    // Deprecated, but must implement
+    @Suppress("DeprecatedCallableAddReplaceWith")
+    @Deprecated("Deprecated in Java")
+    override fun getOpacity(): Int {
+        return PixelFormat.OPAQUE
+    }
+}
diff --git a/pdf/pdf-viewer/src/test/kotlin/androidx/pdf/view/SelectionStateManagerTest.kt b/pdf/pdf-viewer/src/test/kotlin/androidx/pdf/view/SelectionStateManagerTest.kt
new file mode 100644
index 0000000..63d5c25
--- /dev/null
+++ b/pdf/pdf-viewer/src/test/kotlin/androidx/pdf/view/SelectionStateManagerTest.kt
@@ -0,0 +1,192 @@
+/*
+ * Copyright 2024 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.view
+
+import android.graphics.Point
+import android.graphics.PointF
+import android.graphics.RectF
+import androidx.pdf.PdfDocument
+import androidx.pdf.content.PageSelection
+import androidx.pdf.content.PdfPageTextContent
+import androidx.pdf.content.SelectionBoundary
+import androidx.test.filters.SmallTest
+import com.google.common.truth.Truth.assertThat
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.flow.toList
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.test.StandardTestDispatcher
+import kotlinx.coroutines.test.TestScope
+import kotlinx.coroutines.test.UnconfinedTestDispatcher
+import kotlinx.coroutines.test.runTest
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mockito.kotlin.any
+import org.mockito.kotlin.doAnswer
+import org.mockito.kotlin.mock
+import org.robolectric.RobolectricTestRunner
+
+@SmallTest
+@RunWith(RobolectricTestRunner::class)
+class SelectionStateManagerTest {
+    private val testDispatcher = StandardTestDispatcher()
+    private val testScope = TestScope(testDispatcher)
+
+    // TODO(b/385407478) replace with FakePdfDocument when we're able to share it more broadly
+    private val pdfDocument =
+        mock<PdfDocument> {
+            onBlocking { getSelectionBounds(any(), any(), any()) } doAnswer
+                { invocation ->
+                    val startPoint = invocation.getArgument<PointF>(1)
+                    val endPoint = invocation.getArgument<PointF>(2)
+                    pageSelectionFor(invocation.getArgument(0), startPoint, endPoint)
+                }
+        }
+
+    private lateinit var selectionStateManager: SelectionStateManager
+
+    @Before
+    fun setup() {
+        selectionStateManager = SelectionStateManager(pdfDocument, testScope)
+    }
+
+    @OptIn(ExperimentalCoroutinesApi::class)
+    @Test
+    fun maybeSelectWordAtPoint() = runTest {
+        val selectionPoint = PdfPoint(pageNum = 10, PointF(150F, 265F))
+        val invalidations = mutableListOf<Unit>()
+        backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
+            selectionStateManager.invalidationSignalFlow.toList(invalidations)
+        }
+
+        selectionStateManager.maybeSelectWordAtPoint(selectionPoint)
+        testDispatcher.scheduler.runCurrent()
+
+        val selectionModel = selectionStateManager.selectionModel
+        assertThat(selectionModel).isNotNull()
+        assertThat(selectionModel?.selection).isInstanceOf(TextSelection::class.java)
+        val selection = requireNotNull(selectionModel?.selection as TextSelection)
+        assertThat(selection.bounds)
+            .isEqualTo(
+                listOf(
+                    PdfRect(
+                        selectionPoint.pageNum,
+                        RectF(
+                            selectionPoint.pagePoint.x,
+                            selectionPoint.pagePoint.y,
+                            selectionPoint.pagePoint.x,
+                            selectionPoint.pagePoint.y
+                        )
+                    )
+                )
+            )
+        assertThat(selection.text)
+            .isEqualTo(
+                "This is all the text between ${selectionPoint.pagePoint} and ${selectionPoint.pagePoint}"
+            )
+
+        assertThat(invalidations.size).isEqualTo(1)
+    }
+
+    @Test
+    fun maybeSelectWordAtPoint_twice_lastSelectionWins() {
+        val selectionPoint = PdfPoint(pageNum = 10, PointF(150F, 265F))
+        val selectionPoint2 = PdfPoint(pageNum = 10, PointF(250F, 193F))
+
+        selectionStateManager.maybeSelectWordAtPoint(selectionPoint)
+        selectionStateManager.maybeSelectWordAtPoint(selectionPoint2)
+        testDispatcher.scheduler.runCurrent()
+
+        val selectionModel = selectionStateManager.selectionModel
+        assertThat(selectionModel).isNotNull()
+        assertThat(selectionModel?.selection).isInstanceOf(TextSelection::class.java)
+        val selection = requireNotNull(selectionModel?.selection as TextSelection)
+        assertThat(selection.bounds)
+            .isEqualTo(
+                listOf(
+                    PdfRect(
+                        selectionPoint2.pageNum,
+                        RectF(
+                            selectionPoint2.pagePoint.x,
+                            selectionPoint2.pagePoint.y,
+                            selectionPoint2.pagePoint.x,
+                            selectionPoint2.pagePoint.y
+                        )
+                    )
+                )
+            )
+        assertThat(selection.text)
+            .isEqualTo(
+                "This is all the text between ${selectionPoint2.pagePoint} and ${selectionPoint2.pagePoint}"
+            )
+    }
+
+    @OptIn(ExperimentalCoroutinesApi::class)
+    @Test
+    fun clearSelection() = runTest {
+        val selectionPoint = PdfPoint(pageNum = 10, PointF(150F, 265F))
+        val invalidations = mutableListOf<Unit>()
+        backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
+            selectionStateManager.invalidationSignalFlow.toList(invalidations)
+        }
+
+        selectionStateManager.maybeSelectWordAtPoint(selectionPoint)
+        testDispatcher.scheduler.runCurrent()
+        assertThat(selectionStateManager.selectionModel).isNotNull()
+        selectionStateManager.clearSelection()
+
+        assertThat(selectionStateManager.selectionModel).isNull()
+        // One for the selection, one for clearing it
+        assertThat(invalidations.size).isEqualTo(2)
+    }
+
+    @Test
+    fun clearSelection_cancelsWork() {
+        val selectionPoint = PdfPoint(pageNum = 10, PointF(150F, 265F))
+
+        // Start a selection and don't finish it (i.e. no runCurrent)
+        selectionStateManager.maybeSelectWordAtPoint(selectionPoint)
+        assertThat(selectionStateManager.selectionModel).isNull()
+
+        // Clear selection, flush the scheduler, and make sure selection remains null (i.e. the work
+        // enqueued by our initial selection doesn't finish and supersede the cleared state)
+        selectionStateManager.clearSelection()
+        testDispatcher.scheduler.runCurrent()
+        assertThat(selectionStateManager.selectionModel).isNull()
+    }
+
+    private fun pageSelectionFor(page: Int, start: PointF, end: PointF): PageSelection {
+        return PageSelection(
+            page,
+            SelectionBoundary(point = Point(start.x.toInt(), start.y.toInt())),
+            SelectionBoundary(point = Point(end.x.toInt(), end.y.toInt())),
+            listOf(
+                PdfPageTextContent(
+                    listOf(
+                        RectF(
+                            minOf(start.x, end.x),
+                            minOf(start.y, end.y),
+                            maxOf(start.x, end.x),
+                            maxOf(start.y, end.y)
+                        )
+                    ),
+                    text = "This is all the text between $start and $end"
+                )
+            )
+        )
+    }
+}