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"
+ )
+ )
+ )
+ }
+}