1 /*
<lambda>null2  * Copyright 2024 The Android Open Source Project
3  *
4  * Licensed under the Apache License, Version 2.0 (the "License");
5  * you may not use this file except in compliance with the License.
6  * You may obtain a copy of the License at
7  *
8  *      http://www.apache.org/licenses/LICENSE-2.0
9  *
10  * Unless required by applicable law or agreed to in writing, software
11  * distributed under the License is distributed on an "AS IS" BASIS,
12  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13  * See the License for the specific language governing permissions and
14  * limitations under the License.
15  */
16 
17 package androidx.pdf.view
18 
19 import android.graphics.Canvas
20 import android.graphics.Point
21 import android.graphics.Rect
22 import android.util.Range
23 import android.util.SparseArray
24 import androidx.core.util.keyIterator
25 import androidx.core.util.valueIterator
26 import androidx.pdf.PdfDocument
27 import kotlinx.coroutines.CoroutineScope
28 import kotlinx.coroutines.flow.MutableSharedFlow
29 import kotlinx.coroutines.flow.SharedFlow
30 
31 /**
32  * Manages a collection of [Page]s, each representing a single PDF page. Receives events to update
33  * pages' internal state, and produces events via a [SharedFlow] of type [Unit] to signal the host
34  * [PdfView] to invalidate itself when any page needs to be redrawn. Operations like drawing pages
35  * and handling touch events on pages may be delegated to this manager.
36  *
37  * Not thread safe
38  */
39 internal class PageManager(
40     private val pdfDocument: PdfDocument,
41     private val backgroundScope: CoroutineScope,
42     /**
43      * The maximum size of any single [android.graphics.Bitmap] we render for a page, i.e. the
44      * threshold for tiled rendering
45      */
46     private val maxBitmapSizePx: Point,
47     private val isTouchExplorationEnabled: Boolean,
48     /** Error flow for propagating error occurred while processing to [PdfView]. */
49     private val errorFlow: MutableSharedFlow<Throwable>
50 ) {
51     /**
52      * Replay at least 1 value in case of an invalidation signal issued while [PdfView] is not
53      * collecting
54      */
55     private val _invalidationSignalFlow = MutableSharedFlow<Unit>(replay = 1)
56 
57     /**
58      * This [SharedFlow] serves as an event bus of sorts to signal our host [PdfView] to invalidate
59      * itself in a decoupled way. It is of type [Unit] because the reason for invalidation is
60      * inconsequential. The model is: we update the data that affects what will be drawn, we signal
61      * [PdfView] to invalidate itself, and the relevant changes in state will be reflected in the
62      * next call to [PdfView.onDraw]
63      */
64     val invalidationSignalFlow: SharedFlow<Unit>
65         get() = _invalidationSignalFlow
66 
67     internal val pages = SparseArray<Page>()
68 
69     private val _pageTextReadyFlow = MutableSharedFlow<Int>(replay = 1)
70     val pageTextReadyFlow: SharedFlow<Int>
71         get() = _pageTextReadyFlow
72 
73     /**
74      * [Highlight]s supplied by the developer to be drawn along with the pages they belong to
75      *
76      * We store these in a map keyed by page number for more efficient lookup at drawing time, even
77      * though each [Highlight] contains its own page number.
78      */
79     private val highlights: MutableMap<Int, MutableList<Highlight>> = mutableMapOf()
80 
81     /**
82      * Updates the visibility state of [Page]s owned by this manager.
83      *
84      * @param visiblePageAreas the visible area of each visible page, in page coordinates
85      * @param currentZoomLevel the current zoom level
86      * @param stablePosition true if we don't believe our position is actively changing
87      */
88     fun updatePageVisibilities(
89         visiblePageAreas: SparseArray<Rect>,
90         currentZoomLevel: Float,
91         stablePosition: Boolean
92     ) {
93         // Start preparing UI for visible pages
94         visiblePageAreas.keyIterator().forEach { pageNum ->
95             pages[pageNum]?.setVisible(
96                 currentZoomLevel,
97                 visiblePageAreas.get(pageNum),
98                 stablePosition
99             )
100         }
101 
102         // We put pages that are near the viewport in a "nearly visible" state where some data is
103         // retained. We release all data from pages well outside the viewport
104         val nearPages =
105             Range(
106                 maxOf(0, visiblePageAreas.keyAt(0) - PAGE_RETENTION_RADIUS),
107                 minOf(
108                     visiblePageAreas.keyAt(visiblePageAreas.size() - 1) + PAGE_RETENTION_RADIUS,
109                     pdfDocument.pageCount - 1
110                 ),
111             )
112         for (pageNum in pages.keyIterator()) {
113             if (pageNum < nearPages.lower || pageNum > nearPages.upper) {
114                 pages[pageNum]?.setInvisible()
115             } else if (!visiblePageAreas.contains(pageNum)) {
116                 pages[pageNum]?.setNearlyVisible()
117             }
118         }
119     }
120 
121     /**
122      * Updates the set of [Page]s owned by this manager when a new Page's dimensions are loaded.
123      * Dimensions are the minimum data required to instantiate a page.
124      */
125     fun addPage(
126         pageNum: Int,
127         size: Point,
128         currentZoomLevel: Float,
129         stablePosition: Boolean,
130         viewArea: Rect? = null
131     ) {
132         if (pages.contains(pageNum)) return
133         val page =
134             Page(
135                     pageNum,
136                     size,
137                     pdfDocument,
138                     backgroundScope,
139                     maxBitmapSizePx,
140                     isTouchExplorationEnabled,
141                     onPageUpdate = { _invalidationSignalFlow.tryEmit(Unit) },
142                     onPageTextReady = { pageNumber -> _pageTextReadyFlow.tryEmit(pageNumber) },
143                     errorFlow = errorFlow
144                 )
145                 .apply {
146                     // If the page is visible, let it know
147                     if (viewArea != null) {
148                         setVisible(currentZoomLevel, viewArea, stablePosition)
149                     }
150                 }
151         pages.put(pageNum, page)
152     }
153 
154     /** Adds [newHighlights]s to this manager to be drawn along with the pages they belong to */
155     fun setHighlights(newHighlights: List<Highlight>) {
156         highlights.clear()
157         for (highlight in newHighlights) {
158             highlights.getOrPut(highlight.area.pageNum) { mutableListOf() }.add(highlight)
159         }
160         _invalidationSignalFlow.tryEmit(Unit)
161     }
162 
163     /** Draws the [Page] at [pageNum] to the canvas at [locationInView] */
164     fun drawPage(pageNum: Int, canvas: Canvas, locationInView: Rect) {
165         val highlightsForPage = highlights.getOrDefault(pageNum, EMPTY_HIGHLIGHTS)
166         pages.get(pageNum)?.draw(canvas, locationInView, highlightsForPage)
167     }
168 
169     /**
170      * Sets all [Page]s owned by this manager to invisible, i.e. to reduce memory when the host
171      * [PdfView] is not in an interactive state.
172      */
173     fun cleanup() {
174         for (page in pages.valueIterator()) {
175             page.setInvisible()
176         }
177     }
178 
179     fun getLinkAtTapPoint(pdfPoint: PdfPoint): PdfDocument.PdfPageLinks? {
180         return pages[pdfPoint.pageNum]?.links
181     }
182 }
183 
184 /** Constant empty list to avoid allocations during drawing */
185 private val EMPTY_HIGHLIGHTS = listOf<Highlight>()
186 
187 private val PAGE_RETENTION_RADIUS = 2
188