@thatkid02/react-native-pdf-viewer 0.0.2 → 0.0.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +116 -231
- package/android/build.gradle +5 -3
- package/android/src/main/java/com/margelo/nitro/pdfviewer/HybridPdfViewer.kt +10 -0
- package/android/src/main/java/com/margelo/nitro/pdfviewer/PdfViewer.kt +792 -651
- package/ios/PdfViewer.swift +184 -47
- package/lib/typescript/src/PdfViewer.nitro.d.ts +7 -0
- package/lib/typescript/src/PdfViewer.nitro.d.ts.map +1 -1
- package/nitrogen/generated/android/c++/JDocumentInfo.hpp +65 -0
- package/nitrogen/generated/android/c++/JHybridPdfViewerSpec.cpp +9 -0
- package/nitrogen/generated/android/c++/JHybridPdfViewerSpec.hpp +1 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/pdfviewer/DocumentInfo.kt +38 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/pdfviewer/HybridPdfViewerSpec.kt +4 -0
- package/nitrogen/generated/ios/PdfViewer-Swift-Cxx-Bridge.hpp +27 -0
- package/nitrogen/generated/ios/PdfViewer-Swift-Cxx-Umbrella.hpp +3 -0
- package/nitrogen/generated/ios/c++/HybridPdfViewerSpecSwift.hpp +11 -0
- package/nitrogen/generated/ios/swift/DocumentInfo.swift +68 -0
- package/nitrogen/generated/ios/swift/HybridPdfViewerSpec.swift +1 -0
- package/nitrogen/generated/ios/swift/HybridPdfViewerSpec_cxx.swift +18 -0
- package/nitrogen/generated/shared/c++/DocumentInfo.hpp +79 -0
- package/nitrogen/generated/shared/c++/HybridPdfViewerSpec.cpp +1 -0
- package/nitrogen/generated/shared/c++/HybridPdfViewerSpec.hpp +4 -0
- package/package.json +1 -1
- package/src/PdfViewer.nitro.ts +10 -0
|
@@ -4,6 +4,7 @@ import android.content.Context
|
|
|
4
4
|
import android.graphics.Bitmap
|
|
5
5
|
import android.graphics.Canvas
|
|
6
6
|
import android.graphics.Color
|
|
7
|
+
import android.graphics.Rect
|
|
7
8
|
import android.graphics.pdf.PdfRenderer
|
|
8
9
|
import android.os.ParcelFileDescriptor
|
|
9
10
|
import android.util.Log
|
|
@@ -13,11 +14,14 @@ import android.view.GestureDetector
|
|
|
13
14
|
import android.view.MotionEvent
|
|
14
15
|
import android.view.ScaleGestureDetector
|
|
15
16
|
import android.view.View
|
|
16
|
-
import android.
|
|
17
|
+
import android.view.View.MeasureSpec
|
|
17
18
|
import android.widget.FrameLayout
|
|
18
19
|
import android.widget.ImageView
|
|
19
20
|
import android.widget.ProgressBar
|
|
20
21
|
import androidx.core.view.doOnLayout
|
|
22
|
+
import androidx.lifecycle.Lifecycle
|
|
23
|
+
import androidx.lifecycle.LifecycleEventObserver
|
|
24
|
+
import androidx.lifecycle.ProcessLifecycleOwner
|
|
21
25
|
import androidx.recyclerview.widget.LinearLayoutManager
|
|
22
26
|
import androidx.recyclerview.widget.RecyclerView
|
|
23
27
|
import com.facebook.proguard.annotations.DoNotStrip
|
|
@@ -26,6 +30,7 @@ import kotlinx.coroutines.Dispatchers
|
|
|
26
30
|
import kotlinx.coroutines.Job
|
|
27
31
|
import kotlinx.coroutines.SupervisorJob
|
|
28
32
|
import kotlinx.coroutines.cancel
|
|
33
|
+
import kotlinx.coroutines.delay
|
|
29
34
|
import kotlinx.coroutines.launch
|
|
30
35
|
import kotlinx.coroutines.sync.Mutex
|
|
31
36
|
import kotlinx.coroutines.sync.withLock
|
|
@@ -34,56 +39,120 @@ import java.io.File
|
|
|
34
39
|
import java.io.FileOutputStream
|
|
35
40
|
import java.net.URL
|
|
36
41
|
import java.security.MessageDigest
|
|
42
|
+
import java.text.SimpleDateFormat
|
|
43
|
+
import java.util.Date
|
|
44
|
+
import java.util.Locale
|
|
45
|
+
import java.util.TimeZone
|
|
46
|
+
import java.util.concurrent.ConcurrentHashMap
|
|
37
47
|
import kotlin.math.roundToInt
|
|
38
48
|
|
|
49
|
+
/**
|
|
50
|
+
* A high-performance PDF viewer component for React Native with Nitro integration.
|
|
51
|
+
* Supports zooming, thumbnails, page navigation, and efficient memory management.
|
|
52
|
+
*/
|
|
39
53
|
@DoNotStrip
|
|
40
54
|
class PdfViewer(context: Context) : FrameLayout(context) {
|
|
55
|
+
|
|
41
56
|
companion object {
|
|
42
57
|
private const val TAG = "PdfViewer"
|
|
58
|
+
|
|
59
|
+
// Memory management
|
|
43
60
|
private const val CACHE_SIZE_PERCENTAGE = 0.25
|
|
44
|
-
private const val PRELOAD_RANGE = 1
|
|
45
|
-
// Dynamic quality scaling - prevents OOM at high zoom levels
|
|
46
|
-
private const val BASE_RENDER_QUALITY = 1.5f
|
|
47
|
-
// Maximum bitmap dimension to prevent GPU texture limits and OOM
|
|
48
61
|
private const val MAX_BITMAP_DIMENSION = 4096
|
|
49
|
-
|
|
50
|
-
|
|
62
|
+
private const val ESTIMATED_MEMORY_WARNING_MB = 50
|
|
63
|
+
|
|
64
|
+
// Rendering quality (fixed - zoom uses view transformation)
|
|
65
|
+
private const val BASE_RENDER_QUALITY = 1.5f
|
|
66
|
+
|
|
67
|
+
// Preloading
|
|
68
|
+
private const val PRELOAD_RANGE = 1
|
|
69
|
+
private const val INITIAL_PRELOAD_COUNT = 10
|
|
70
|
+
private const val INITIAL_PRELOAD_DELAY_MS = 50L
|
|
71
|
+
private const val LAZY_PRELOAD_DELAY_MS = 100L
|
|
72
|
+
|
|
73
|
+
// UI
|
|
74
|
+
private const val LOADING_INDICATOR_SIZE_DP = 48
|
|
75
|
+
private const val THUMBNAIL_SIZE = 120
|
|
76
|
+
private const val THUMBNAIL_QUALITY = 85
|
|
77
|
+
private const val VIEW_CACHE_SIZE = 10
|
|
78
|
+
private const val RECYCLED_VIEW_POOL_SIZE = 20
|
|
79
|
+
|
|
80
|
+
// Network
|
|
81
|
+
private const val CONNECT_TIMEOUT_MS = 30_000
|
|
82
|
+
private const val READ_TIMEOUT_MS = 60_000
|
|
83
|
+
private const val CACHE_VALIDITY_MS = 3600_000L
|
|
84
|
+
|
|
85
|
+
// Default PDF page dimensions (US Letter)
|
|
86
|
+
private const val DEFAULT_PAGE_WIDTH = 612
|
|
87
|
+
private const val DEFAULT_PAGE_HEIGHT = 792
|
|
51
88
|
}
|
|
52
89
|
|
|
53
|
-
// Core PDF
|
|
90
|
+
// region Core PDF Components
|
|
91
|
+
|
|
54
92
|
private var pdfRenderer: PdfRenderer? = null
|
|
55
93
|
private var parcelFileDescriptor: ParcelFileDescriptor? = null
|
|
56
94
|
private val renderMutex = Mutex()
|
|
57
|
-
|
|
58
|
-
// Coroutine scope with SupervisorJob for error isolation
|
|
59
95
|
private val componentScope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
|
|
60
96
|
|
|
61
|
-
//
|
|
97
|
+
// endregion
|
|
98
|
+
|
|
99
|
+
// region UI Components
|
|
100
|
+
|
|
62
101
|
private val recyclerView: RecyclerView
|
|
63
102
|
private val loadingIndicator: ProgressBar
|
|
64
103
|
private var adapter: PdfPageAdapter? = null
|
|
65
104
|
|
|
66
|
-
//
|
|
105
|
+
// endregion
|
|
106
|
+
|
|
107
|
+
// region Gesture Handling
|
|
108
|
+
|
|
67
109
|
private val scaleGestureDetector: ScaleGestureDetector
|
|
68
110
|
private val gestureDetector: GestureDetector
|
|
69
|
-
private var isScaling = false
|
|
111
|
+
@Volatile private var isScaling = false
|
|
112
|
+
|
|
113
|
+
// endregion
|
|
114
|
+
|
|
115
|
+
// region Rendering State
|
|
70
116
|
|
|
71
|
-
// Rendering state
|
|
72
117
|
private val activeRenderJobs = mutableMapOf<Int, Job>()
|
|
73
118
|
private val bitmapCache: LruCache<String, Bitmap>
|
|
74
|
-
private val pageDimensions =
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
119
|
+
private val pageDimensions = ConcurrentHashMap<Int, PageDimension>()
|
|
120
|
+
|
|
121
|
+
// endregion
|
|
122
|
+
|
|
123
|
+
// region Thumbnail Cache
|
|
124
|
+
|
|
125
|
+
private val thumbnailCache = ConcurrentHashMap<String, ConcurrentHashMap<Int, String>>()
|
|
126
|
+
private val pendingThumbnails: MutableSet<Int> = ConcurrentHashMap.newKeySet()
|
|
127
|
+
|
|
128
|
+
// endregion
|
|
129
|
+
|
|
130
|
+
// region Document State
|
|
131
|
+
|
|
132
|
+
@Volatile private var currentLoadJob: Job? = null
|
|
133
|
+
@Volatile private var documentHash: String? = null
|
|
134
|
+
|
|
135
|
+
private val lifecycleObserver = LifecycleEventObserver { _, event ->
|
|
136
|
+
if (event == Lifecycle.Event.ON_STOP) {
|
|
137
|
+
cleanupAllThumbnails()
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
private val prefs by lazy {
|
|
142
|
+
context.getSharedPreferences("pdf_viewer_state", Context.MODE_PRIVATE)
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// endregion
|
|
146
|
+
|
|
147
|
+
// region Public Properties
|
|
78
148
|
|
|
79
|
-
// Props
|
|
80
149
|
private var _sourceUri: String? = null
|
|
81
150
|
var sourceUri: String?
|
|
82
151
|
get() = _sourceUri
|
|
83
152
|
set(value) {
|
|
84
153
|
if (_sourceUri != value) {
|
|
85
154
|
_sourceUri = value
|
|
86
|
-
value?.let
|
|
155
|
+
value?.let(::loadDocument)
|
|
87
156
|
}
|
|
88
157
|
}
|
|
89
158
|
|
|
@@ -95,36 +164,24 @@ class PdfViewer(context: Context) : FrameLayout(context) {
|
|
|
95
164
|
}
|
|
96
165
|
}
|
|
97
166
|
|
|
98
|
-
// Note: horizontal and enablePaging are iOS-only features
|
|
99
|
-
// Android always uses vertical scroll
|
|
100
167
|
var spacing: Float = 8f
|
|
101
168
|
var enableZoom: Boolean = true
|
|
102
169
|
var minScale: Float = 0.5f
|
|
103
170
|
var maxScale: Float = 4.0f
|
|
104
171
|
|
|
105
|
-
// Content insets for glass UI / transparent bars
|
|
106
172
|
var contentInsetTop: Float = 0f
|
|
107
|
-
set(value) {
|
|
108
|
-
field = value
|
|
109
|
-
updateContentInsets()
|
|
110
|
-
}
|
|
173
|
+
set(value) { field = value; updateContentInsets() }
|
|
111
174
|
var contentInsetBottom: Float = 0f
|
|
112
|
-
set(value) {
|
|
113
|
-
field = value
|
|
114
|
-
updateContentInsets()
|
|
115
|
-
}
|
|
175
|
+
set(value) { field = value; updateContentInsets() }
|
|
116
176
|
var contentInsetLeft: Float = 0f
|
|
117
|
-
set(value) {
|
|
118
|
-
field = value
|
|
119
|
-
updateContentInsets()
|
|
120
|
-
}
|
|
177
|
+
set(value) { field = value; updateContentInsets() }
|
|
121
178
|
var contentInsetRight: Float = 0f
|
|
122
|
-
set(value) {
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
179
|
+
set(value) { field = value; updateContentInsets() }
|
|
180
|
+
|
|
181
|
+
// endregion
|
|
182
|
+
|
|
183
|
+
// region Callbacks
|
|
126
184
|
|
|
127
|
-
// Callbacks for HybridPdfViewer integration
|
|
128
185
|
var onLoadCompleteCallback: ((pageCount: Int, pageWidth: Int, pageHeight: Int) -> Unit)? = null
|
|
129
186
|
var onPageChangeCallback: ((page: Int, pageCount: Int) -> Unit)? = null
|
|
130
187
|
var onScaleChangeCallback: ((scale: Float) -> Unit)? = null
|
|
@@ -132,50 +189,37 @@ class PdfViewer(context: Context) : FrameLayout(context) {
|
|
|
132
189
|
var onThumbnailGeneratedCallback: ((page: Int, uri: String) -> Unit)? = null
|
|
133
190
|
var onLoadingChangeCallback: ((isLoading: Boolean) -> Unit)? = null
|
|
134
191
|
|
|
135
|
-
//
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
private var
|
|
192
|
+
// endregion
|
|
193
|
+
|
|
194
|
+
// region Runtime State
|
|
195
|
+
|
|
196
|
+
@Volatile private var currentScale = 1.0f
|
|
197
|
+
@Volatile private var lastReportedPage = -1
|
|
198
|
+
@Volatile private var isLoading = false
|
|
199
|
+
@Volatile private var viewWidth = 0
|
|
200
|
+
@Volatile private var needsInitialLayout = true
|
|
201
|
+
|
|
202
|
+
// Zoom/Pan state
|
|
203
|
+
private var translateX = 0f
|
|
204
|
+
private var translateY = 0f
|
|
205
|
+
private var lastTouchX = 0f
|
|
206
|
+
private var lastTouchY = 0f
|
|
207
|
+
private var isPanning = false
|
|
208
|
+
private var activePointerId = MotionEvent.INVALID_POINTER_ID
|
|
209
|
+
|
|
210
|
+
// endregion
|
|
211
|
+
|
|
212
|
+
/** Simple data class for page dimensions */
|
|
213
|
+
private data class PageDimension(val width: Int, val height: Int)
|
|
140
214
|
|
|
141
215
|
init {
|
|
142
|
-
|
|
143
|
-
layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)
|
|
144
|
-
setHasFixedSize(false)
|
|
145
|
-
setItemViewCacheSize(4)
|
|
146
|
-
recycledViewPool.setMaxRecycledViews(0, 12)
|
|
147
|
-
|
|
148
|
-
// Allow parent to intercept touch events for pinch-to-zoom
|
|
149
|
-
requestDisallowInterceptTouchEvent(false)
|
|
150
|
-
|
|
151
|
-
// Enable drawing content under padding (for glass UI effect)
|
|
152
|
-
clipToPadding = false
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
loadingIndicator = ProgressBar(context).apply {
|
|
156
|
-
val size = (48 * context.resources.displayMetrics.density).toInt()
|
|
157
|
-
layoutParams = LayoutParams(size, size).apply {
|
|
158
|
-
gravity = android.view.Gravity.CENTER
|
|
159
|
-
}
|
|
160
|
-
isIndeterminate = true
|
|
161
|
-
visibility = View.GONE
|
|
162
|
-
}
|
|
216
|
+
ProcessLifecycleOwner.get().lifecycle.addObserver(lifecycleObserver)
|
|
163
217
|
|
|
218
|
+
recyclerView = createRecyclerView()
|
|
219
|
+
loadingIndicator = createLoadingIndicator()
|
|
164
220
|
scaleGestureDetector = ScaleGestureDetector(context, ScaleListener())
|
|
165
221
|
gestureDetector = GestureDetector(context, GestureListener())
|
|
166
|
-
|
|
167
|
-
// Initialize bitmap cache (25% of available memory)
|
|
168
|
-
val maxMemory = (Runtime.getRuntime().maxMemory() / 1024).toInt()
|
|
169
|
-
val cacheSize = (maxMemory * CACHE_SIZE_PERCENTAGE).toInt()
|
|
170
|
-
|
|
171
|
-
bitmapCache = object : LruCache<String, Bitmap>(cacheSize) {
|
|
172
|
-
override fun sizeOf(key: String, bitmap: Bitmap): Int = bitmap.byteCount / 1024
|
|
173
|
-
|
|
174
|
-
override fun entryRemoved(evicted: Boolean, key: String, oldValue: Bitmap?, newValue: Bitmap?) {
|
|
175
|
-
// CRITICAL: Don't recycle bitmaps - RecyclerView may still be using them
|
|
176
|
-
// Let GC handle cleanup. Only recycle on component unmount.
|
|
177
|
-
}
|
|
178
|
-
}
|
|
222
|
+
bitmapCache = createBitmapCache()
|
|
179
223
|
|
|
180
224
|
setupRecyclerView()
|
|
181
225
|
applySpacing()
|
|
@@ -183,7 +227,6 @@ class PdfViewer(context: Context) : FrameLayout(context) {
|
|
|
183
227
|
addView(recyclerView)
|
|
184
228
|
addView(loadingIndicator)
|
|
185
229
|
|
|
186
|
-
// Measure view width for proper rendering
|
|
187
230
|
doOnLayout {
|
|
188
231
|
val newWidth = width
|
|
189
232
|
if (newWidth > 0 && newWidth != viewWidth) {
|
|
@@ -193,32 +236,84 @@ class PdfViewer(context: Context) : FrameLayout(context) {
|
|
|
193
236
|
}
|
|
194
237
|
}
|
|
195
238
|
|
|
239
|
+
private fun createRecyclerView(): RecyclerView = RecyclerView(context).apply {
|
|
240
|
+
layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)
|
|
241
|
+
setHasFixedSize(false)
|
|
242
|
+
setItemViewCacheSize(VIEW_CACHE_SIZE)
|
|
243
|
+
recycledViewPool.setMaxRecycledViews(0, RECYCLED_VIEW_POOL_SIZE)
|
|
244
|
+
requestDisallowInterceptTouchEvent(false)
|
|
245
|
+
clipToPadding = false
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
private fun createLoadingIndicator(): ProgressBar = ProgressBar(context).apply {
|
|
249
|
+
val size = (LOADING_INDICATOR_SIZE_DP * context.resources.displayMetrics.density).toInt()
|
|
250
|
+
layoutParams = LayoutParams(size, size).apply {
|
|
251
|
+
gravity = android.view.Gravity.CENTER
|
|
252
|
+
}
|
|
253
|
+
isIndeterminate = true
|
|
254
|
+
visibility = View.GONE
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
private fun createBitmapCache(): LruCache<String, Bitmap> {
|
|
258
|
+
val maxMemoryKb = (Runtime.getRuntime().maxMemory() / 1024).toInt()
|
|
259
|
+
val cacheSize = (maxMemoryKb * CACHE_SIZE_PERCENTAGE).toInt()
|
|
260
|
+
|
|
261
|
+
return object : LruCache<String, Bitmap>(cacheSize) {
|
|
262
|
+
override fun sizeOf(key: String, bitmap: Bitmap): Int = bitmap.byteCount / 1024
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
196
266
|
override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) {
|
|
197
267
|
super.onLayout(changed, left, top, right, bottom)
|
|
198
268
|
|
|
199
|
-
// Update viewWidth whenever layout changes
|
|
200
269
|
val newWidth = right - left
|
|
201
270
|
if (newWidth > 0 && newWidth != viewWidth) {
|
|
202
271
|
viewWidth = newWidth
|
|
203
272
|
post { adapter?.notifyDataSetChanged() }
|
|
204
273
|
}
|
|
274
|
+
|
|
275
|
+
if (needsInitialLayout && newWidth > 0 && bottom > 0 && pdfRenderer != null) {
|
|
276
|
+
needsInitialLayout = false
|
|
277
|
+
scheduleInitialLayout(newWidth, bottom - top)
|
|
278
|
+
}
|
|
205
279
|
}
|
|
206
280
|
|
|
207
|
-
private fun
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
281
|
+
private fun scheduleInitialLayout(width: Int, height: Int) {
|
|
282
|
+
recyclerView.viewTreeObserver.addOnPreDrawListener(
|
|
283
|
+
object : android.view.ViewTreeObserver.OnPreDrawListener {
|
|
284
|
+
override fun onPreDraw(): Boolean {
|
|
285
|
+
recyclerView.viewTreeObserver.removeOnPreDrawListener(this)
|
|
286
|
+
recyclerView.measure(
|
|
287
|
+
MeasureSpec.makeMeasureSpec(width, MeasureSpec.EXACTLY),
|
|
288
|
+
MeasureSpec.makeMeasureSpec(height, MeasureSpec.EXACTLY)
|
|
289
|
+
)
|
|
290
|
+
recyclerView.layout(0, 0, width, height)
|
|
291
|
+
preloadVisibleAndAdjacentPages()
|
|
292
|
+
return true
|
|
214
293
|
}
|
|
215
294
|
}
|
|
216
|
-
|
|
295
|
+
)
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
private fun applySpacing() {
|
|
299
|
+
recyclerView.addItemDecoration(PageSpacingDecoration())
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
private inner class PageSpacingDecoration : RecyclerView.ItemDecoration() {
|
|
303
|
+
override fun getItemOffsets(
|
|
304
|
+
outRect: Rect,
|
|
305
|
+
view: View,
|
|
306
|
+
parent: RecyclerView,
|
|
307
|
+
state: RecyclerView.State
|
|
308
|
+
) {
|
|
309
|
+
val position = parent.getChildAdapterPosition(view)
|
|
310
|
+
if (position > 0) {
|
|
311
|
+
outRect.top = spacing.toInt()
|
|
312
|
+
}
|
|
313
|
+
}
|
|
217
314
|
}
|
|
218
315
|
|
|
219
316
|
private fun updateContentInsets() {
|
|
220
|
-
// Apply content insets as padding
|
|
221
|
-
// clipToPadding=false allows content to draw under padding (glass UI effect)
|
|
222
317
|
recyclerView.setPadding(
|
|
223
318
|
contentInsetLeft.toInt(),
|
|
224
319
|
contentInsetTop.toInt(),
|
|
@@ -228,183 +323,204 @@ class PdfViewer(context: Context) : FrameLayout(context) {
|
|
|
228
323
|
}
|
|
229
324
|
|
|
230
325
|
private fun setupRecyclerView() {
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
326
|
+
recyclerView.layoutManager = LinearLayoutManager(context, LinearLayoutManager.VERTICAL, false)
|
|
327
|
+
recyclerView.addOnScrollListener(PageScrollListener())
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
private inner class PageScrollListener : RecyclerView.OnScrollListener() {
|
|
331
|
+
private var scrollState = RecyclerView.SCROLL_STATE_IDLE
|
|
234
332
|
|
|
235
|
-
recyclerView
|
|
236
|
-
|
|
333
|
+
override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
|
|
334
|
+
val layoutManager = recyclerView.layoutManager as? LinearLayoutManager ?: return
|
|
335
|
+
val firstVisible = layoutManager.findFirstVisibleItemPosition()
|
|
237
336
|
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
val firstVisible = layoutManager.findFirstVisibleItemPosition()
|
|
243
|
-
|
|
244
|
-
if (firstVisible >= 0 && firstVisible != lastReportedPage) {
|
|
245
|
-
lastReportedPage = firstVisible
|
|
246
|
-
emitPageChange(firstVisible)
|
|
247
|
-
}
|
|
248
|
-
|
|
249
|
-
if (scrollState == RecyclerView.SCROLL_STATE_IDLE) {
|
|
250
|
-
schedulePreload()
|
|
251
|
-
}
|
|
337
|
+
if (firstVisible >= 0 && firstVisible != lastReportedPage) {
|
|
338
|
+
lastReportedPage = firstVisible
|
|
339
|
+
emitPageChange(firstVisible)
|
|
340
|
+
persistCurrentPage(firstVisible)
|
|
252
341
|
}
|
|
253
342
|
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
scrollState = newState
|
|
257
|
-
|
|
258
|
-
if (newState == RecyclerView.SCROLL_STATE_IDLE) {
|
|
259
|
-
schedulePreload()
|
|
260
|
-
}
|
|
343
|
+
if (scrollState == RecyclerView.SCROLL_STATE_IDLE) {
|
|
344
|
+
schedulePreload()
|
|
261
345
|
}
|
|
262
|
-
}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
override fun onScrollStateChanged(recyclerView: RecyclerView, newState: Int) {
|
|
349
|
+
scrollState = newState
|
|
350
|
+
if (newState == RecyclerView.SCROLL_STATE_IDLE) {
|
|
351
|
+
schedulePreload()
|
|
352
|
+
}
|
|
353
|
+
}
|
|
263
354
|
}
|
|
264
355
|
|
|
356
|
+
private fun persistCurrentPage(page: Int) {
|
|
357
|
+
documentHash?.let { hash ->
|
|
358
|
+
prefs.edit().putInt("page_$hash", page).apply()
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
// region Touch Handling
|
|
363
|
+
|
|
265
364
|
override fun onTouchEvent(event: MotionEvent): Boolean {
|
|
266
|
-
|
|
365
|
+
if (!enableZoom) {
|
|
366
|
+
return recyclerView.onTouchEvent(event)
|
|
367
|
+
}
|
|
267
368
|
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
369
|
+
// Always let scale detector examine events
|
|
370
|
+
scaleGestureDetector.onTouchEvent(event)
|
|
371
|
+
gestureDetector.onTouchEvent(event)
|
|
372
|
+
|
|
373
|
+
if (scaleGestureDetector.isInProgress) {
|
|
374
|
+
isScaling = true
|
|
375
|
+
isPanning = false
|
|
376
|
+
return true
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
// Handle panning when zoomed in
|
|
380
|
+
if (currentScale > 1.0f) {
|
|
381
|
+
when (event.actionMasked) {
|
|
382
|
+
MotionEvent.ACTION_DOWN -> {
|
|
383
|
+
activePointerId = event.getPointerId(0)
|
|
384
|
+
lastTouchX = event.x
|
|
385
|
+
lastTouchY = event.y
|
|
386
|
+
isPanning = true
|
|
387
|
+
parent?.requestDisallowInterceptTouchEvent(true)
|
|
388
|
+
}
|
|
389
|
+
MotionEvent.ACTION_MOVE -> {
|
|
390
|
+
if (isPanning && !isScaling) {
|
|
391
|
+
val pointerIndex = event.findPointerIndex(activePointerId)
|
|
392
|
+
if (pointerIndex >= 0) {
|
|
393
|
+
val x = event.getX(pointerIndex)
|
|
394
|
+
val y = event.getY(pointerIndex)
|
|
395
|
+
|
|
396
|
+
val dx = x - lastTouchX
|
|
397
|
+
val dy = y - lastTouchY
|
|
398
|
+
|
|
399
|
+
applyTranslation(dx, dy)
|
|
400
|
+
|
|
401
|
+
lastTouchX = x
|
|
402
|
+
lastTouchY = y
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> {
|
|
407
|
+
isPanning = false
|
|
408
|
+
isScaling = false
|
|
409
|
+
activePointerId = MotionEvent.INVALID_POINTER_ID
|
|
410
|
+
parent?.requestDisallowInterceptTouchEvent(false)
|
|
411
|
+
}
|
|
412
|
+
MotionEvent.ACTION_POINTER_UP -> {
|
|
413
|
+
val pointerIndex = event.actionIndex
|
|
414
|
+
val pointerId = event.getPointerId(pointerIndex)
|
|
415
|
+
if (pointerId == activePointerId) {
|
|
416
|
+
val newPointerIndex = if (pointerIndex == 0) 1 else 0
|
|
417
|
+
if (newPointerIndex < event.pointerCount) {
|
|
418
|
+
lastTouchX = event.getX(newPointerIndex)
|
|
419
|
+
lastTouchY = event.getY(newPointerIndex)
|
|
420
|
+
activePointerId = event.getPointerId(newPointerIndex)
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
}
|
|
281
424
|
}
|
|
425
|
+
return true
|
|
282
426
|
}
|
|
283
427
|
|
|
428
|
+
// At scale 1.0, let RecyclerView handle scrolling
|
|
284
429
|
if (event.action == MotionEvent.ACTION_UP || event.action == MotionEvent.ACTION_CANCEL) {
|
|
285
430
|
isScaling = false
|
|
286
|
-
|
|
287
|
-
recyclerView.requestDisallowInterceptTouchEvent(false)
|
|
288
|
-
}
|
|
289
|
-
|
|
290
|
-
// Pass to children if not scaling
|
|
291
|
-
if (!handled) {
|
|
292
|
-
handled = recyclerView.onTouchEvent(event)
|
|
431
|
+
isPanning = false
|
|
293
432
|
}
|
|
294
433
|
|
|
295
|
-
return
|
|
434
|
+
return recyclerView.onTouchEvent(event)
|
|
296
435
|
}
|
|
297
436
|
|
|
298
437
|
override fun onInterceptTouchEvent(event: MotionEvent): Boolean {
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
438
|
+
if (!enableZoom) return false
|
|
439
|
+
|
|
440
|
+
scaleGestureDetector.onTouchEvent(event)
|
|
441
|
+
|
|
442
|
+
// Intercept if scaling or zoomed in (to handle panning)
|
|
443
|
+
if (scaleGestureDetector.isInProgress) return true
|
|
444
|
+
if (currentScale > 1.0f && event.pointerCount == 1) return true
|
|
445
|
+
|
|
446
|
+
return false
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
private fun applyTranslation(dx: Float, dy: Float) {
|
|
450
|
+
// Calculate bounds for panning
|
|
451
|
+
val scaledWidth = width * currentScale
|
|
452
|
+
val scaledHeight = recyclerView.computeVerticalScrollRange() * currentScale
|
|
453
|
+
|
|
454
|
+
val maxTranslateX = (scaledWidth - width) / 2f
|
|
455
|
+
val maxTranslateY = (scaledHeight - height) / 2f
|
|
456
|
+
|
|
457
|
+
translateX = (translateX + dx).coerceIn(-maxTranslateX, maxTranslateX)
|
|
458
|
+
translateY = (translateY + dy).coerceIn(-maxTranslateY, maxTranslateY)
|
|
459
|
+
|
|
460
|
+
recyclerView.translationX = translateX
|
|
461
|
+
recyclerView.translationY = translateY
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
private fun applyZoomTransform(focusX: Float, focusY: Float) {
|
|
465
|
+
recyclerView.pivotX = focusX
|
|
466
|
+
recyclerView.pivotY = focusY
|
|
467
|
+
recyclerView.scaleX = currentScale
|
|
468
|
+
recyclerView.scaleY = currentScale
|
|
469
|
+
|
|
470
|
+
// Constrain translation after scale change
|
|
471
|
+
if (currentScale <= 1.0f) {
|
|
472
|
+
translateX = 0f
|
|
473
|
+
translateY = 0f
|
|
474
|
+
recyclerView.translationX = 0f
|
|
475
|
+
recyclerView.translationY = 0f
|
|
476
|
+
} else {
|
|
477
|
+
applyTranslation(0f, 0f)
|
|
305
478
|
}
|
|
306
|
-
return super.onInterceptTouchEvent(event)
|
|
307
479
|
}
|
|
308
480
|
|
|
481
|
+
private fun resetZoomTransform() {
|
|
482
|
+
currentScale = 1.0f
|
|
483
|
+
translateX = 0f
|
|
484
|
+
translateY = 0f
|
|
485
|
+
recyclerView.scaleX = 1.0f
|
|
486
|
+
recyclerView.scaleY = 1.0f
|
|
487
|
+
recyclerView.translationX = 0f
|
|
488
|
+
recyclerView.translationY = 0f
|
|
489
|
+
recyclerView.pivotX = width / 2f
|
|
490
|
+
recyclerView.pivotY = height / 2f
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
// endregion
|
|
494
|
+
|
|
495
|
+
// region Document Loading
|
|
496
|
+
|
|
309
497
|
fun loadDocument(uri: String?) {
|
|
310
|
-
if (BuildConfig.DEBUG)
|
|
311
|
-
|
|
312
|
-
}
|
|
498
|
+
if (BuildConfig.DEBUG) Log.d(TAG, "loadDocument: $uri")
|
|
499
|
+
|
|
313
500
|
if (uri.isNullOrBlank()) {
|
|
314
501
|
Log.e(TAG, "URI is null or blank")
|
|
315
502
|
emitError("URI cannot be empty", "INVALID_URI")
|
|
316
503
|
return
|
|
317
504
|
}
|
|
318
505
|
|
|
319
|
-
|
|
320
|
-
if (isLoading) {
|
|
321
|
-
if (BuildConfig.DEBUG) {
|
|
322
|
-
Log.w(TAG, "Canceling previous document load")
|
|
323
|
-
}
|
|
324
|
-
currentLoadJob?.cancel()
|
|
325
|
-
currentLoadJob = null
|
|
326
|
-
}
|
|
327
|
-
|
|
506
|
+
currentLoadJob?.cancel()
|
|
328
507
|
isLoading = true
|
|
329
508
|
setLoadingState(true)
|
|
330
509
|
showLoading(true)
|
|
331
510
|
cancelAllRenderJobs()
|
|
332
|
-
|
|
333
|
-
// Clear cache without recycling (GC will handle it)
|
|
334
511
|
bitmapCache.evictAll()
|
|
335
512
|
|
|
336
513
|
currentLoadJob = componentScope.launch(Dispatchers.IO) {
|
|
337
514
|
try {
|
|
338
|
-
if (BuildConfig.DEBUG) {
|
|
339
|
-
Log.d(TAG, "Starting document download/load for uri: $uri")
|
|
340
|
-
}
|
|
341
515
|
val file = downloadOrGetFile(uri)
|
|
342
|
-
if (BuildConfig.DEBUG) {
|
|
343
|
-
Log.d(TAG, "File obtained: ${file.absolutePath}, exists: ${file.exists()}, canRead: ${file.canRead()}")
|
|
344
|
-
}
|
|
345
|
-
|
|
346
516
|
require(file.exists()) { "File does not exist: ${file.absolutePath}" }
|
|
347
517
|
require(file.canRead()) { "Cannot read file: ${file.absolutePath}" }
|
|
348
518
|
|
|
349
519
|
val fd = ParcelFileDescriptor.open(file, ParcelFileDescriptor.MODE_READ_ONLY)
|
|
350
520
|
val renderer = PdfRenderer(fd)
|
|
351
|
-
if (BuildConfig.DEBUG) {
|
|
352
|
-
Log.d(TAG, "PdfRenderer created successfully, pageCount: ${renderer.pageCount}")
|
|
353
|
-
}
|
|
354
|
-
|
|
355
521
|
require(renderer.pageCount > 0) { "PDF has no pages" }
|
|
356
522
|
|
|
357
|
-
|
|
358
|
-
closePdfRenderer()
|
|
359
|
-
|
|
360
|
-
pdfRenderer = renderer
|
|
361
|
-
parcelFileDescriptor = fd
|
|
362
|
-
bitmapCache.evictAll()
|
|
363
|
-
pageDimensions.clear()
|
|
364
|
-
thumbnailCache.clear()
|
|
365
|
-
pendingThumbnails.clear()
|
|
366
|
-
lastReportedPage = -1
|
|
367
|
-
|
|
368
|
-
// Get first page dimensions immediately for initial render
|
|
369
|
-
val firstDim = try {
|
|
370
|
-
renderMutex.withLock {
|
|
371
|
-
renderer.openPage(0).use { page ->
|
|
372
|
-
Pair(page.width, page.height).also {
|
|
373
|
-
pageDimensions[0] = it
|
|
374
|
-
}
|
|
375
|
-
}
|
|
376
|
-
}
|
|
377
|
-
} catch (e: Exception) {
|
|
378
|
-
Log.e(TAG, "Error reading first page", e)
|
|
379
|
-
Pair(612, 792)
|
|
380
|
-
}
|
|
381
|
-
|
|
382
|
-
// Preload a few more page dimensions in background (non-blocking)
|
|
383
|
-
// This helps with initial scrolling but doesn't delay the initial render
|
|
384
|
-
componentScope.launch(Dispatchers.IO) {
|
|
385
|
-
preloadInitialPageDimensions(renderer)
|
|
386
|
-
}
|
|
387
|
-
|
|
388
|
-
adapter = PdfPageAdapter()
|
|
389
|
-
recyclerView.adapter = adapter
|
|
390
|
-
|
|
391
|
-
showLoading(false)
|
|
392
|
-
setLoadingState(false)
|
|
393
|
-
|
|
394
|
-
if (BuildConfig.DEBUG) {
|
|
395
|
-
Log.d(TAG, "Emitting loadComplete: pageCount=${renderer.pageCount}, width=${firstDim.first}, height=${firstDim.second}")
|
|
396
|
-
}
|
|
397
|
-
emitLoadComplete(renderer.pageCount, firstDim.first, firstDim.second)
|
|
398
|
-
|
|
399
|
-
// Force initial layout and render by scrolling slightly
|
|
400
|
-
// This ensures content appears without requiring user interaction
|
|
401
|
-
post {
|
|
402
|
-
recyclerView.scrollBy(0, 2)
|
|
403
|
-
recyclerView.scrollBy(0, -2)
|
|
404
|
-
}
|
|
405
|
-
|
|
406
|
-
schedulePreload()
|
|
407
|
-
}
|
|
523
|
+
initializeDocument(renderer, fd)
|
|
408
524
|
} catch (e: SecurityException) {
|
|
409
525
|
handleLoadError("PDF is password protected or encrypted", "SECURITY_ERROR", e)
|
|
410
526
|
} catch (e: java.io.FileNotFoundException) {
|
|
@@ -421,6 +537,80 @@ class PdfViewer(context: Context) : FrameLayout(context) {
|
|
|
421
537
|
}
|
|
422
538
|
}
|
|
423
539
|
|
|
540
|
+
private suspend fun initializeDocument(renderer: PdfRenderer, fd: ParcelFileDescriptor) {
|
|
541
|
+
val firstDim = loadFirstPageDimensions(renderer)
|
|
542
|
+
|
|
543
|
+
componentScope.launch(Dispatchers.IO) {
|
|
544
|
+
preloadInitialPageDimensions(renderer)
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
withContext(Dispatchers.Main) {
|
|
548
|
+
closePdfRenderer()
|
|
549
|
+
|
|
550
|
+
pdfRenderer = renderer
|
|
551
|
+
parcelFileDescriptor = fd
|
|
552
|
+
documentHash = computeDocumentHash()
|
|
553
|
+
|
|
554
|
+
resetDocumentState()
|
|
555
|
+
|
|
556
|
+
adapter = PdfPageAdapter()
|
|
557
|
+
recyclerView.adapter = adapter
|
|
558
|
+
|
|
559
|
+
showLoading(false)
|
|
560
|
+
setLoadingState(false)
|
|
561
|
+
|
|
562
|
+
emitLoadComplete(renderer.pageCount, firstDim.width, firstDim.height)
|
|
563
|
+
restoreLastViewedPage(renderer)
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
private suspend fun loadFirstPageDimensions(renderer: PdfRenderer): PageDimension {
|
|
568
|
+
return try {
|
|
569
|
+
renderMutex.withLock {
|
|
570
|
+
renderer.openPage(0).use { page ->
|
|
571
|
+
PageDimension(page.width, page.height).also {
|
|
572
|
+
pageDimensions[0] = it
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
} catch (e: Exception) {
|
|
577
|
+
Log.e(TAG, "Error reading first page", e)
|
|
578
|
+
PageDimension(DEFAULT_PAGE_WIDTH, DEFAULT_PAGE_HEIGHT)
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
private fun resetDocumentState() {
|
|
583
|
+
bitmapCache.evictAll()
|
|
584
|
+
pageDimensions.clear()
|
|
585
|
+
pendingThumbnails.clear()
|
|
586
|
+
lastReportedPage = -1
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
private fun restoreLastViewedPage(renderer: PdfRenderer) {
|
|
590
|
+
post {
|
|
591
|
+
val lastPage = documentHash?.let { prefs.getInt("page_$it", 0) } ?: 0
|
|
592
|
+
|
|
593
|
+
Choreographer.getInstance().postFrameCallback {
|
|
594
|
+
if (lastPage > 0 && lastPage < renderer.pageCount) {
|
|
595
|
+
recyclerView.scrollToPosition(lastPage)
|
|
596
|
+
lastReportedPage = lastPage
|
|
597
|
+
emitPageChange(lastPage)
|
|
598
|
+
} else {
|
|
599
|
+
lastReportedPage = 0
|
|
600
|
+
emitPageChange(0)
|
|
601
|
+
preloadInitialPages()
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
private fun preloadInitialPages() {
|
|
608
|
+
val pageCount = pdfRenderer?.pageCount ?: return
|
|
609
|
+
repeat(minOf(PRELOAD_RANGE + 1, pageCount)) { i ->
|
|
610
|
+
startPageRender(i)
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
|
|
424
614
|
private suspend fun handleLoadError(message: String, code: String, error: Exception) {
|
|
425
615
|
Log.e(TAG, message, error)
|
|
426
616
|
withContext(Dispatchers.Main) {
|
|
@@ -430,58 +620,45 @@ class PdfViewer(context: Context) : FrameLayout(context) {
|
|
|
430
620
|
}
|
|
431
621
|
}
|
|
432
622
|
|
|
433
|
-
// Preload only first few pages (non-blocking) for better initial experience
|
|
434
623
|
private suspend fun preloadInitialPageDimensions(renderer: PdfRenderer) {
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
renderMutex.withLock {
|
|
441
|
-
if (pageDimensions[i] == null) { // Only if not already loaded
|
|
442
|
-
renderer.openPage(i).use { page ->
|
|
443
|
-
pageDimensions[i] = Pair(page.width, page.height)
|
|
444
|
-
}
|
|
445
|
-
}
|
|
446
|
-
}
|
|
447
|
-
} catch (e: Exception) {
|
|
448
|
-
Log.e(TAG, "Error preloading page $i dimensions", e)
|
|
449
|
-
}
|
|
624
|
+
val pagesToPreload = minOf(INITIAL_PRELOAD_COUNT, renderer.pageCount)
|
|
625
|
+
|
|
626
|
+
// Preload first batch with short delay
|
|
627
|
+
for (i in 1 until pagesToPreload) {
|
|
628
|
+
loadPageDimensionIfMissing(renderer, i, INITIAL_PRELOAD_DELAY_MS)
|
|
450
629
|
}
|
|
451
630
|
|
|
452
|
-
// Lazily load remaining pages with
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
631
|
+
// Lazily load remaining pages with longer delay
|
|
632
|
+
for (i in pagesToPreload until renderer.pageCount) {
|
|
633
|
+
loadPageDimensionIfMissing(renderer, i, LAZY_PRELOAD_DELAY_MS)
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
private suspend fun loadPageDimensionIfMissing(renderer: PdfRenderer, index: Int, delayMs: Long) {
|
|
638
|
+
try {
|
|
639
|
+
delay(delayMs)
|
|
640
|
+
renderMutex.withLock {
|
|
641
|
+
if (pageDimensions[index] == null) {
|
|
642
|
+
renderer.openPage(index).use { page ->
|
|
643
|
+
pageDimensions[index] = PageDimension(page.width, page.height)
|
|
463
644
|
}
|
|
464
|
-
} catch (e: Exception) {
|
|
465
|
-
Log.e(TAG, "Error preloading page $i dimensions", e)
|
|
466
645
|
}
|
|
467
646
|
}
|
|
647
|
+
} catch (e: Exception) {
|
|
648
|
+
Log.e(TAG, "Error preloading page $index dimensions", e)
|
|
468
649
|
}
|
|
469
650
|
}
|
|
470
651
|
|
|
471
|
-
|
|
472
|
-
private suspend fun getPageDimensions(pageIndex: Int): Pair<Int, Int> {
|
|
473
|
-
// Return cached if available
|
|
652
|
+
private suspend fun getPageDimensions(pageIndex: Int): PageDimension {
|
|
474
653
|
pageDimensions[pageIndex]?.let { return it }
|
|
475
654
|
|
|
476
|
-
|
|
477
|
-
val renderer = pdfRenderer ?: return Pair(612, 792) // Standard page size fallback
|
|
655
|
+
val renderer = pdfRenderer ?: return PageDimension(DEFAULT_PAGE_WIDTH, DEFAULT_PAGE_HEIGHT)
|
|
478
656
|
|
|
479
657
|
return try {
|
|
480
658
|
renderMutex.withLock {
|
|
481
|
-
// Check again inside lock in case another thread loaded it
|
|
482
659
|
pageDimensions[pageIndex] ?: run {
|
|
483
660
|
renderer.openPage(pageIndex).use { page ->
|
|
484
|
-
|
|
661
|
+
PageDimension(page.width, page.height).also {
|
|
485
662
|
pageDimensions[pageIndex] = it
|
|
486
663
|
}
|
|
487
664
|
}
|
|
@@ -489,26 +666,88 @@ class PdfViewer(context: Context) : FrameLayout(context) {
|
|
|
489
666
|
}
|
|
490
667
|
} catch (e: Exception) {
|
|
491
668
|
Log.e(TAG, "Error loading page $pageIndex dimensions", e)
|
|
492
|
-
|
|
669
|
+
PageDimension(DEFAULT_PAGE_WIDTH, DEFAULT_PAGE_HEIGHT)
|
|
493
670
|
}
|
|
494
671
|
}
|
|
495
672
|
|
|
673
|
+
// endregion
|
|
674
|
+
|
|
675
|
+
// region File Handling
|
|
676
|
+
|
|
496
677
|
private fun closePdfRenderer() {
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
} catch (e: Exception) {
|
|
500
|
-
Log.e(TAG, "Error closing renderer", e)
|
|
501
|
-
}
|
|
678
|
+
runCatching { pdfRenderer?.close() }
|
|
679
|
+
.onFailure { Log.e(TAG, "Error closing renderer", it) }
|
|
502
680
|
pdfRenderer = null
|
|
503
681
|
|
|
682
|
+
runCatching { parcelFileDescriptor?.close() }
|
|
683
|
+
.onFailure { Log.e(TAG, "Error closing file descriptor", it) }
|
|
684
|
+
parcelFileDescriptor = null
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
private suspend fun downloadOrGetFile(uri: String): File = withContext(Dispatchers.IO) {
|
|
688
|
+
when {
|
|
689
|
+
uri.startsWith("file://") -> File(uri.removePrefix("file://"))
|
|
690
|
+
uri.startsWith("http://") || uri.startsWith("https://") -> downloadPdf(uri)
|
|
691
|
+
else -> File(uri)
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
private fun downloadPdf(uri: String): File {
|
|
696
|
+
val hash = uri.hashCode().toString()
|
|
697
|
+
val file = File(context.cacheDir, "pdf_$hash.pdf")
|
|
698
|
+
val tempFile = File(context.cacheDir, "pdf_${hash}_temp.pdf")
|
|
699
|
+
|
|
700
|
+
if (file.exists() && isCacheValid(file)) {
|
|
701
|
+
if (BuildConfig.DEBUG) Log.d(TAG, "Using cached PDF: ${file.absolutePath}")
|
|
702
|
+
return file
|
|
703
|
+
}
|
|
704
|
+
|
|
504
705
|
try {
|
|
505
|
-
|
|
706
|
+
val connection = URL(uri).openConnection().apply {
|
|
707
|
+
connectTimeout = CONNECT_TIMEOUT_MS
|
|
708
|
+
readTimeout = READ_TIMEOUT_MS
|
|
709
|
+
setRequestProperty("Accept", "application/pdf")
|
|
710
|
+
addConditionalHeaders(file)
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
connection.getInputStream().use { input ->
|
|
714
|
+
FileOutputStream(tempFile).use { output ->
|
|
715
|
+
input.copyTo(output, bufferSize = 8192)
|
|
716
|
+
}
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
file.delete()
|
|
720
|
+
tempFile.renameTo(file)
|
|
721
|
+
|
|
722
|
+
if (BuildConfig.DEBUG) Log.d(TAG, "Download completed: ${file.absolutePath}")
|
|
506
723
|
} catch (e: Exception) {
|
|
507
|
-
|
|
724
|
+
tempFile.delete()
|
|
725
|
+
if (file.exists()) {
|
|
726
|
+
Log.w(TAG, "Download failed, using cached version: ${e.message}")
|
|
727
|
+
return file
|
|
728
|
+
}
|
|
729
|
+
throw e
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
return file
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
private fun isCacheValid(file: File): Boolean =
|
|
736
|
+
System.currentTimeMillis() - file.lastModified() < CACHE_VALIDITY_MS
|
|
737
|
+
|
|
738
|
+
private fun java.net.URLConnection.addConditionalHeaders(cachedFile: File) {
|
|
739
|
+
if (cachedFile.exists()) {
|
|
740
|
+
val dateFormat = SimpleDateFormat("EEE, dd MMM yyyy HH:mm:ss z", Locale.US).apply {
|
|
741
|
+
timeZone = TimeZone.getTimeZone("GMT")
|
|
742
|
+
}
|
|
743
|
+
setRequestProperty("If-Modified-Since", dateFormat.format(Date(cachedFile.lastModified())))
|
|
508
744
|
}
|
|
509
|
-
parcelFileDescriptor = null
|
|
510
745
|
}
|
|
511
746
|
|
|
747
|
+
// endregion
|
|
748
|
+
|
|
749
|
+
// region Loading State
|
|
750
|
+
|
|
512
751
|
private fun showLoading(show: Boolean) {
|
|
513
752
|
loadingIndicator.visibility = if (show && showsActivityIndicator) View.VISIBLE else View.GONE
|
|
514
753
|
recyclerView.visibility = if (show) View.INVISIBLE else View.VISIBLE
|
|
@@ -532,204 +771,109 @@ class PdfViewer(context: Context) : FrameLayout(context) {
|
|
|
532
771
|
}
|
|
533
772
|
}
|
|
534
773
|
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
uri.startsWith("http://") || uri.startsWith("https://") -> {
|
|
539
|
-
val hash = uri.hashCode().toString()
|
|
540
|
-
val file = File(context.cacheDir, "pdf_$hash.pdf")
|
|
541
|
-
val tempFile = File(context.cacheDir, "pdf_${hash}_temp.pdf")
|
|
542
|
-
|
|
543
|
-
// Check if cached file exists and is reasonably fresh (< 1 hour)
|
|
544
|
-
if (file.exists() && (System.currentTimeMillis() - file.lastModified() < 3600_000)) {
|
|
545
|
-
if (BuildConfig.DEBUG) {
|
|
546
|
-
Log.d(TAG, "Using cached PDF file: ${file.absolutePath}")
|
|
547
|
-
}
|
|
548
|
-
return@withContext file
|
|
549
|
-
}
|
|
550
|
-
|
|
551
|
-
// Download with better error handling and progress
|
|
552
|
-
try {
|
|
553
|
-
val url = URL(uri)
|
|
554
|
-
val connection = url.openConnection().apply {
|
|
555
|
-
connectTimeout = 30_000 // 30 seconds
|
|
556
|
-
readTimeout = 60_000 // 60 seconds
|
|
557
|
-
setRequestProperty("Accept", "application/pdf")
|
|
558
|
-
|
|
559
|
-
// Add cache headers for better HTTP caching
|
|
560
|
-
if (file.exists()) {
|
|
561
|
-
setRequestProperty("If-Modified-Since",
|
|
562
|
-
java.text.SimpleDateFormat("EEE, dd MMM yyyy HH:mm:ss z", java.util.Locale.US)
|
|
563
|
-
.apply { timeZone = java.util.TimeZone.getTimeZone("GMT") }
|
|
564
|
-
.format(java.util.Date(file.lastModified())))
|
|
565
|
-
}
|
|
566
|
-
}
|
|
567
|
-
|
|
568
|
-
val contentLength = connection.contentLength
|
|
569
|
-
if (BuildConfig.DEBUG) {
|
|
570
|
-
Log.d(TAG, "Downloading PDF: $uri, size: $contentLength bytes")
|
|
571
|
-
}
|
|
572
|
-
|
|
573
|
-
connection.getInputStream().use { input ->
|
|
574
|
-
FileOutputStream(tempFile).use { output ->
|
|
575
|
-
val buffer = ByteArray(8192)
|
|
576
|
-
var bytesRead: Int
|
|
577
|
-
var totalRead = 0
|
|
578
|
-
|
|
579
|
-
while (input.read(buffer).also { bytesRead = it } != -1) {
|
|
580
|
-
output.write(buffer, 0, bytesRead)
|
|
581
|
-
totalRead += bytesRead
|
|
582
|
-
|
|
583
|
-
// Log progress for large files
|
|
584
|
-
if (BuildConfig.DEBUG && contentLength > 0 && totalRead % (contentLength / 10 + 1) == 0) {
|
|
585
|
-
val progress = (totalRead * 100) / contentLength
|
|
586
|
-
Log.d(TAG, "Download progress: $progress%")
|
|
587
|
-
}
|
|
588
|
-
}
|
|
589
|
-
|
|
590
|
-
output.flush()
|
|
591
|
-
}
|
|
592
|
-
}
|
|
593
|
-
|
|
594
|
-
// Move temp file to final location
|
|
595
|
-
if (tempFile.exists()) {
|
|
596
|
-
file.delete() // Remove old cached file
|
|
597
|
-
tempFile.renameTo(file)
|
|
598
|
-
}
|
|
599
|
-
|
|
600
|
-
if (BuildConfig.DEBUG) {
|
|
601
|
-
Log.d(TAG, "Download completed: ${file.absolutePath}")
|
|
602
|
-
}
|
|
603
|
-
} catch (e: Exception) {
|
|
604
|
-
// Clean up temp file on error
|
|
605
|
-
tempFile.delete()
|
|
606
|
-
|
|
607
|
-
// If download failed but we have an old cached version, use it
|
|
608
|
-
if (file.exists()) {
|
|
609
|
-
Log.w(TAG, "Download failed, using cached version: ${e.message}")
|
|
610
|
-
return@withContext file
|
|
611
|
-
}
|
|
612
|
-
|
|
613
|
-
throw e
|
|
614
|
-
}
|
|
615
|
-
|
|
616
|
-
file
|
|
617
|
-
}
|
|
618
|
-
else -> File(uri)
|
|
619
|
-
}
|
|
620
|
-
}
|
|
774
|
+
// endregion
|
|
775
|
+
|
|
776
|
+
// region Public API
|
|
621
777
|
|
|
622
778
|
fun goToPage(page: Int) {
|
|
623
|
-
val renderer = pdfRenderer ?:
|
|
624
|
-
|
|
625
|
-
return
|
|
626
|
-
}
|
|
779
|
+
val renderer = pdfRenderer ?: return emitError("PDF not loaded", "NOT_LOADED")
|
|
780
|
+
|
|
627
781
|
if (page !in 0 until renderer.pageCount) {
|
|
628
782
|
emitError("Invalid page: $page. Valid range: 0-${renderer.pageCount - 1}", "INVALID_PAGE")
|
|
629
783
|
return
|
|
630
784
|
}
|
|
631
785
|
|
|
632
|
-
if (BuildConfig.DEBUG)
|
|
633
|
-
|
|
786
|
+
if (BuildConfig.DEBUG) Log.d(TAG, "goToPage: $page")
|
|
787
|
+
|
|
788
|
+
// Reset zoom when navigating to a page
|
|
789
|
+
if (currentScale != 1.0f) {
|
|
790
|
+
resetZoomTransform()
|
|
791
|
+
emitScaleChange(1.0f)
|
|
634
792
|
}
|
|
635
793
|
|
|
636
794
|
post {
|
|
637
|
-
// Use smoothScrollToPosition for animated scroll
|
|
638
795
|
recyclerView.smoothScrollToPosition(page)
|
|
639
|
-
|
|
640
|
-
// Emit page change event for UI sync
|
|
641
796
|
emitPageChange(page)
|
|
642
|
-
|
|
643
797
|
postDelayed({ schedulePreload() }, 100)
|
|
644
798
|
}
|
|
645
799
|
}
|
|
646
800
|
|
|
647
801
|
fun setScale(scale: Float) {
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
802
|
+
setScale(scale, width / 2f, height / 2f)
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
private fun setScale(scale: Float, focusX: Float, focusY: Float) {
|
|
806
|
+
if (!enableZoom) return emitError("Zoom is disabled", "ZOOM_DISABLED")
|
|
652
807
|
|
|
653
808
|
val clampedScale = scale.coerceIn(minScale, maxScale)
|
|
654
|
-
if ((clampedScale - currentScale)
|
|
809
|
+
if (kotlin.math.abs(clampedScale - currentScale) < 0.01f) return
|
|
655
810
|
|
|
656
|
-
if (BuildConfig.DEBUG)
|
|
657
|
-
Log.d(TAG, "setScale: $clampedScale (was $currentScale)")
|
|
658
|
-
}
|
|
659
|
-
currentScale = clampedScale
|
|
660
|
-
|
|
661
|
-
// Clear cache but DON'T recycle bitmaps (RecyclerView may still be drawing them)
|
|
662
|
-
bitmapCache.evictAll()
|
|
811
|
+
if (BuildConfig.DEBUG) Log.d(TAG, "setScale: $clampedScale (was $currentScale)")
|
|
663
812
|
|
|
664
|
-
|
|
665
|
-
|
|
813
|
+
currentScale = clampedScale
|
|
814
|
+
applyZoomTransform(focusX, focusY)
|
|
666
815
|
emitScaleChange(currentScale)
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
fun getDocumentInfo(): Map<String, Any>? {
|
|
819
|
+
val renderer = pdfRenderer ?: return null
|
|
820
|
+
if (renderer.pageCount == 0) return null
|
|
667
821
|
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
postDelayed({ schedulePreload() }, 150)
|
|
680
|
-
}
|
|
822
|
+
val page = renderer.openPage(0)
|
|
823
|
+
val info: Map<String, Any> = mapOf(
|
|
824
|
+
"pageCount" to renderer.pageCount,
|
|
825
|
+
"pageWidth" to page.width,
|
|
826
|
+
"pageHeight" to page.height,
|
|
827
|
+
"currentPage" to ((recyclerView.layoutManager as? LinearLayoutManager)
|
|
828
|
+
?.findFirstVisibleItemPosition() ?: 0)
|
|
829
|
+
)
|
|
830
|
+
page.close()
|
|
831
|
+
|
|
832
|
+
return info
|
|
681
833
|
}
|
|
682
834
|
|
|
835
|
+
// endregion
|
|
836
|
+
|
|
837
|
+
// region Thumbnail Generation
|
|
838
|
+
|
|
683
839
|
fun generateThumbnail(page: Int) {
|
|
684
|
-
if (BuildConfig.DEBUG)
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
emitError("PDF not loaded", "NOT_LOADED")
|
|
689
|
-
return
|
|
690
|
-
}
|
|
840
|
+
if (BuildConfig.DEBUG) Log.d(TAG, "generateThumbnail: $page")
|
|
841
|
+
|
|
842
|
+
val renderer = pdfRenderer ?: return emitError("PDF not loaded", "NOT_LOADED")
|
|
843
|
+
|
|
691
844
|
if (page !in 0 until renderer.pageCount) {
|
|
692
845
|
emitError("Invalid page: $page. Valid range: 0-${renderer.pageCount - 1}", "INVALID_PAGE")
|
|
693
846
|
return
|
|
694
847
|
}
|
|
695
848
|
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
}
|
|
849
|
+
val hash = documentHash ?: return emitError("Document hash not available", "HASH_ERROR")
|
|
850
|
+
|
|
851
|
+
// Check memory cache
|
|
852
|
+
thumbnailCache[hash]?.get(page)?.let { cachedUri ->
|
|
701
853
|
emitThumbnailGenerated(page, cachedUri)
|
|
702
854
|
return
|
|
703
855
|
}
|
|
704
856
|
|
|
705
|
-
// Check
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
}
|
|
857
|
+
// Check disk cache
|
|
858
|
+
val diskPath = getThumbnailPath(hash, page)
|
|
859
|
+
if (File(diskPath).exists()) {
|
|
860
|
+
val uri = "file://$diskPath"
|
|
861
|
+
thumbnailCache.getOrPut(hash) { ConcurrentHashMap() }[page] = uri
|
|
862
|
+
emitThumbnailGenerated(page, uri)
|
|
710
863
|
return
|
|
711
864
|
}
|
|
712
865
|
|
|
866
|
+
// Skip if already being generated
|
|
867
|
+
if (!pendingThumbnails.add(page)) return
|
|
868
|
+
|
|
713
869
|
componentScope.launch(Dispatchers.IO) {
|
|
714
870
|
try {
|
|
715
|
-
if (BuildConfig.DEBUG) {
|
|
716
|
-
Log.d(TAG, "Starting thumbnail generation for page $page")
|
|
717
|
-
}
|
|
718
871
|
val thumbnail = renderThumbnail(page)
|
|
719
|
-
|
|
720
|
-
Log.d(TAG, "Thumbnail bitmap created: ${thumbnail.width}x${thumbnail.height}")
|
|
721
|
-
}
|
|
722
|
-
val uri = saveThumbnailToCache(thumbnail, page)
|
|
723
|
-
if (BuildConfig.DEBUG) {
|
|
724
|
-
Log.d(TAG, "Thumbnail saved to: $uri")
|
|
725
|
-
}
|
|
872
|
+
val uri = saveThumbnailToCache(thumbnail, page, hash)
|
|
726
873
|
|
|
727
|
-
thumbnailCache[page] = uri
|
|
874
|
+
thumbnailCache.getOrPut(hash) { ConcurrentHashMap() }[page] = uri
|
|
728
875
|
|
|
729
876
|
withContext(Dispatchers.Main) {
|
|
730
|
-
if (BuildConfig.DEBUG) {
|
|
731
|
-
Log.d(TAG, "Emitting thumbnail generated event for page $page")
|
|
732
|
-
}
|
|
733
877
|
emitThumbnailGenerated(page, uri)
|
|
734
878
|
}
|
|
735
879
|
} catch (e: Exception) {
|
|
@@ -745,49 +889,109 @@ class PdfViewer(context: Context) : FrameLayout(context) {
|
|
|
745
889
|
|
|
746
890
|
fun generateAllThumbnails() {
|
|
747
891
|
val renderer = pdfRenderer ?: return
|
|
892
|
+
val hash = documentHash ?: return
|
|
748
893
|
|
|
749
894
|
componentScope.launch(Dispatchers.IO) {
|
|
750
895
|
for (i in 0 until renderer.pageCount) {
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
896
|
+
generateThumbnailForPage(i, hash)
|
|
897
|
+
delay(50)
|
|
898
|
+
}
|
|
899
|
+
}
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
private suspend fun generateThumbnailForPage(page: Int, hash: String) {
|
|
903
|
+
// Check memory cache
|
|
904
|
+
thumbnailCache[hash]?.get(page)?.let { cachedUri ->
|
|
905
|
+
withContext(Dispatchers.Main) { emitThumbnailGenerated(page, cachedUri) }
|
|
906
|
+
return
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
// Check disk cache
|
|
910
|
+
val diskPath = getThumbnailPath(hash, page)
|
|
911
|
+
if (File(diskPath).exists()) {
|
|
912
|
+
val uri = "file://$diskPath"
|
|
913
|
+
thumbnailCache.getOrPut(hash) { ConcurrentHashMap() }[page] = uri
|
|
914
|
+
withContext(Dispatchers.Main) { emitThumbnailGenerated(page, uri) }
|
|
915
|
+
return
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
if (!pendingThumbnails.add(page)) return
|
|
919
|
+
|
|
920
|
+
try {
|
|
921
|
+
val thumbnail = renderThumbnail(page)
|
|
922
|
+
val uri = saveThumbnailToCache(thumbnail, page, hash)
|
|
923
|
+
|
|
924
|
+
thumbnailCache.getOrPut(hash) { ConcurrentHashMap() }[page] = uri
|
|
925
|
+
withContext(Dispatchers.Main) { emitThumbnailGenerated(page, uri) }
|
|
926
|
+
} catch (e: Exception) {
|
|
927
|
+
Log.e(TAG, "Error generating thumbnail for page $page", e)
|
|
928
|
+
withContext(Dispatchers.Main) {
|
|
929
|
+
emitError("Thumbnail $page failed: ${e.message}", "THUMBNAIL_ERROR")
|
|
930
|
+
}
|
|
931
|
+
} finally {
|
|
932
|
+
pendingThumbnails.remove(page)
|
|
933
|
+
}
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
private suspend fun renderThumbnail(pageIndex: Int): Bitmap = withContext(Dispatchers.IO) {
|
|
937
|
+
val renderer = pdfRenderer ?: throw IllegalStateException("No renderer")
|
|
938
|
+
|
|
939
|
+
renderMutex.withLock {
|
|
940
|
+
renderer.openPage(pageIndex).use { page ->
|
|
941
|
+
val aspectRatio = page.height.toFloat() / page.width.toFloat()
|
|
942
|
+
val thumbWidth = THUMBNAIL_SIZE
|
|
943
|
+
val thumbHeight = (THUMBNAIL_SIZE * aspectRatio).roundToInt()
|
|
764
944
|
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
thumbnailCache[i] = uri
|
|
770
|
-
|
|
771
|
-
withContext(Dispatchers.Main) {
|
|
772
|
-
emitThumbnailGenerated(i, uri)
|
|
773
|
-
}
|
|
774
|
-
|
|
775
|
-
// Small delay to avoid overwhelming the system
|
|
776
|
-
kotlinx.coroutines.delay(50)
|
|
777
|
-
} catch (e: Exception) {
|
|
778
|
-
Log.e(TAG, "Error generating thumbnail for page $i", e)
|
|
779
|
-
withContext(Dispatchers.Main) {
|
|
780
|
-
emitError("Thumbnail $i failed: ${e.message}", "THUMBNAIL_ERROR")
|
|
781
|
-
}
|
|
782
|
-
} finally {
|
|
783
|
-
pendingThumbnails.remove(i)
|
|
945
|
+
Bitmap.createBitmap(thumbWidth, thumbHeight, Bitmap.Config.ARGB_8888).apply {
|
|
946
|
+
Canvas(this).drawColor(Color.WHITE)
|
|
947
|
+
page.render(this, null, null, PdfRenderer.Page.RENDER_MODE_FOR_DISPLAY)
|
|
784
948
|
}
|
|
785
949
|
}
|
|
786
950
|
}
|
|
787
951
|
}
|
|
788
952
|
|
|
953
|
+
private fun saveThumbnailToCache(bitmap: Bitmap, page: Int, hash: String): String {
|
|
954
|
+
val hashDir = File(context.cacheDir, "PDFThumbnails/$hash").apply { mkdirs() }
|
|
955
|
+
val file = File(hashDir, "thumb_$page.jpg")
|
|
956
|
+
|
|
957
|
+
FileOutputStream(file).use { out ->
|
|
958
|
+
bitmap.compress(Bitmap.CompressFormat.JPEG, THUMBNAIL_QUALITY, out)
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
if (!bitmap.isRecycled) bitmap.recycle()
|
|
962
|
+
|
|
963
|
+
return "file://${file.absolutePath}"
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
private fun getThumbnailPath(hash: String, page: Int): String =
|
|
967
|
+
"${context.cacheDir}/PDFThumbnails/$hash/thumb_$page.jpg"
|
|
968
|
+
|
|
969
|
+
private fun computeDocumentHash(): String {
|
|
970
|
+
val uri = sourceUri ?: return "unknown"
|
|
971
|
+
return try {
|
|
972
|
+
MessageDigest.getInstance("MD5")
|
|
973
|
+
.digest(uri.toByteArray())
|
|
974
|
+
.joinToString("") { "%02x".format(it) }
|
|
975
|
+
.take(8)
|
|
976
|
+
} catch (e: Exception) {
|
|
977
|
+
"unknown"
|
|
978
|
+
}
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
private fun cleanupAllThumbnails() {
|
|
982
|
+
runCatching {
|
|
983
|
+
File(context.cacheDir, "PDFThumbnails").deleteRecursively()
|
|
984
|
+
Log.d(TAG, "Cleaned up all thumbnail cache directories")
|
|
985
|
+
}.onFailure {
|
|
986
|
+
Log.e(TAG, "Error cleaning up thumbnail directories", it)
|
|
987
|
+
}
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
// endregion
|
|
991
|
+
|
|
992
|
+
// region Page Rendering
|
|
993
|
+
|
|
789
994
|
private fun schedulePreload() {
|
|
790
|
-
// Use Choreographer to schedule on next frame
|
|
791
995
|
Choreographer.getInstance().postFrameCallback {
|
|
792
996
|
preloadVisibleAndAdjacentPages()
|
|
793
997
|
}
|
|
@@ -805,8 +1009,7 @@ class PdfViewer(context: Context) : FrameLayout(context) {
|
|
|
805
1009
|
val endPage = (lastVisible + PRELOAD_RANGE).coerceAtMost(pageCount - 1)
|
|
806
1010
|
|
|
807
1011
|
for (i in startPage..endPage) {
|
|
808
|
-
|
|
809
|
-
if (bitmapCache.get(cacheKey) == null) {
|
|
1012
|
+
if (bitmapCache.get("$i") == null) {
|
|
810
1013
|
startPageRender(i)
|
|
811
1014
|
}
|
|
812
1015
|
}
|
|
@@ -818,9 +1021,8 @@ class PdfViewer(context: Context) : FrameLayout(context) {
|
|
|
818
1021
|
|
|
819
1022
|
val job = componentScope.launch(Dispatchers.IO) {
|
|
820
1023
|
try {
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
val cacheKey = "$pageIndex-${currentScale.toString().take(4)}"
|
|
1024
|
+
renderPage(pageIndex)?.let { bitmap ->
|
|
1025
|
+
val cacheKey = "$pageIndex"
|
|
824
1026
|
bitmapCache.put(cacheKey, bitmap)
|
|
825
1027
|
|
|
826
1028
|
withContext(Dispatchers.Main) {
|
|
@@ -847,71 +1049,18 @@ class PdfViewer(context: Context) : FrameLayout(context) {
|
|
|
847
1049
|
try {
|
|
848
1050
|
renderMutex.withLock {
|
|
849
1051
|
renderer.openPage(pageIndex).use { page ->
|
|
850
|
-
|
|
851
|
-
val baseScale = viewWidth.toFloat() / page.width
|
|
852
|
-
|
|
853
|
-
// Dynamic quality scaling: reduce quality at higher zoom levels
|
|
854
|
-
// This prevents exponential bitmap growth and OOM errors
|
|
855
|
-
val renderQuality = when {
|
|
856
|
-
currentScale <= 1.0f -> BASE_RENDER_QUALITY // Normal zoom: 1.5x quality
|
|
857
|
-
currentScale <= HIGH_ZOOM_THRESHOLD -> 1.2f // Slight zoom: 1.2x quality
|
|
858
|
-
currentScale <= 2.0f -> 1.0f // Medium zoom: 1.0x quality
|
|
859
|
-
currentScale <= 3.0f -> 0.85f // High zoom: 0.85x quality
|
|
860
|
-
else -> kotlin.math.max(0.7f, 2.5f / currentScale) // Very high zoom: inverse scaling
|
|
861
|
-
}
|
|
1052
|
+
val (bitmapWidth, bitmapHeight) = calculateBitmapDimensions(page)
|
|
862
1053
|
|
|
863
|
-
|
|
864
|
-
val totalScale = baseScale * currentScale * renderQuality
|
|
865
|
-
var bitmapWidth = (page.width * totalScale).toInt().coerceAtLeast(1)
|
|
866
|
-
var bitmapHeight = (page.height * totalScale).toInt().coerceAtLeast(1)
|
|
1054
|
+
logBitmapStats(pageIndex, bitmapWidth, bitmapHeight)
|
|
867
1055
|
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
val aspectRatio = page.width.toFloat() / page.height.toFloat()
|
|
872
|
-
if (bitmapWidth > bitmapHeight) {
|
|
873
|
-
bitmapWidth = MAX_BITMAP_DIMENSION
|
|
874
|
-
bitmapHeight = (MAX_BITMAP_DIMENSION / aspectRatio).toInt()
|
|
875
|
-
} else {
|
|
876
|
-
bitmapHeight = MAX_BITMAP_DIMENSION
|
|
877
|
-
bitmapWidth = (MAX_BITMAP_DIMENSION * aspectRatio).toInt()
|
|
878
|
-
}
|
|
879
|
-
if (BuildConfig.DEBUG) {
|
|
880
|
-
Log.w(TAG, "Page $pageIndex bitmap capped to ${bitmapWidth}x${bitmapHeight} (scale: $currentScale, quality: $renderQuality)")
|
|
881
|
-
}
|
|
1056
|
+
Bitmap.createBitmap(bitmapWidth, bitmapHeight, Bitmap.Config.ARGB_8888).apply {
|
|
1057
|
+
Canvas(this).drawColor(Color.WHITE)
|
|
1058
|
+
page.render(this, null, null, PdfRenderer.Page.RENDER_MODE_FOR_DISPLAY)
|
|
882
1059
|
}
|
|
883
|
-
|
|
884
|
-
// Calculate approximate memory usage
|
|
885
|
-
val estimatedMemoryMB = (bitmapWidth * bitmapHeight * 4) / (1024 * 1024)
|
|
886
|
-
if (estimatedMemoryMB > 50) {
|
|
887
|
-
if (BuildConfig.DEBUG) {
|
|
888
|
-
Log.w(TAG, "Page $pageIndex: Large bitmap ${bitmapWidth}x${bitmapHeight} (~${estimatedMemoryMB}MB)")
|
|
889
|
-
}
|
|
890
|
-
}
|
|
891
|
-
|
|
892
|
-
if (BuildConfig.DEBUG) {
|
|
893
|
-
Log.d(TAG, "Rendering page $pageIndex: ${bitmapWidth}x${bitmapHeight} (scale: $currentScale, quality: $renderQuality, totalScale: $totalScale)")
|
|
894
|
-
}
|
|
895
|
-
|
|
896
|
-
// ARGB_8888 is required for PdfRenderer
|
|
897
|
-
val bitmap = Bitmap.createBitmap(bitmapWidth, bitmapHeight, Bitmap.Config.ARGB_8888)
|
|
898
|
-
|
|
899
|
-
Canvas(bitmap).drawColor(Color.WHITE)
|
|
900
|
-
page.render(bitmap, null, null, PdfRenderer.Page.RENDER_MODE_FOR_DISPLAY)
|
|
901
|
-
|
|
902
|
-
bitmap
|
|
903
1060
|
}
|
|
904
1061
|
}
|
|
905
1062
|
} catch (e: OutOfMemoryError) {
|
|
906
|
-
|
|
907
|
-
// Clear cache and try to recover memory
|
|
908
|
-
bitmapCache.evictAll()
|
|
909
|
-
System.gc()
|
|
910
|
-
|
|
911
|
-
// Emit error to inform user
|
|
912
|
-
withContext(Dispatchers.Main) {
|
|
913
|
-
emitError("Out of memory at zoom level ${currentScale}x. Try reducing zoom.", "OOM_ERROR")
|
|
914
|
-
}
|
|
1063
|
+
handleOutOfMemoryError(pageIndex, e)
|
|
915
1064
|
null
|
|
916
1065
|
} catch (e: IllegalArgumentException) {
|
|
917
1066
|
Log.e(TAG, "Invalid bitmap dimensions for page $pageIndex", e)
|
|
@@ -922,72 +1071,54 @@ class PdfViewer(context: Context) : FrameLayout(context) {
|
|
|
922
1071
|
}
|
|
923
1072
|
}
|
|
924
1073
|
|
|
925
|
-
private
|
|
926
|
-
|
|
1074
|
+
private fun calculateBitmapDimensions(page: PdfRenderer.Page): Pair<Int, Int> {
|
|
1075
|
+
// Render at view width with quality multiplier (zoom is handled by view transform)
|
|
1076
|
+
val baseScale = viewWidth.toFloat() / page.width
|
|
1077
|
+
val totalScale = baseScale * BASE_RENDER_QUALITY
|
|
927
1078
|
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
1079
|
+
var bitmapWidth = (page.width * totalScale).toInt().coerceAtLeast(1)
|
|
1080
|
+
var bitmapHeight = (page.height * totalScale).toInt().coerceAtLeast(1)
|
|
1081
|
+
|
|
1082
|
+
// Enforce maximum bitmap dimensions
|
|
1083
|
+
if (bitmapWidth > MAX_BITMAP_DIMENSION || bitmapHeight > MAX_BITMAP_DIMENSION) {
|
|
1084
|
+
val aspectRatio = page.width.toFloat() / page.height.toFloat()
|
|
1085
|
+
if (bitmapWidth > bitmapHeight) {
|
|
1086
|
+
bitmapWidth = MAX_BITMAP_DIMENSION
|
|
1087
|
+
bitmapHeight = (MAX_BITMAP_DIMENSION / aspectRatio).toInt()
|
|
1088
|
+
} else {
|
|
1089
|
+
bitmapHeight = MAX_BITMAP_DIMENSION
|
|
1090
|
+
bitmapWidth = (MAX_BITMAP_DIMENSION * aspectRatio).toInt()
|
|
940
1091
|
}
|
|
941
1092
|
}
|
|
1093
|
+
|
|
1094
|
+
return Pair(bitmapWidth, bitmapHeight)
|
|
942
1095
|
}
|
|
943
1096
|
|
|
944
|
-
private fun
|
|
945
|
-
|
|
946
|
-
if (!exists()) mkdirs()
|
|
947
|
-
}
|
|
1097
|
+
private fun logBitmapStats(pageIndex: Int, width: Int, height: Int) {
|
|
1098
|
+
if (!BuildConfig.DEBUG) return
|
|
948
1099
|
|
|
949
|
-
val
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
FileOutputStream(file).use { out ->
|
|
953
|
-
bitmap.compress(Bitmap.CompressFormat.JPEG, 85, out)
|
|
1100
|
+
val estimatedMemoryMB = (width * height * 4) / (1024 * 1024)
|
|
1101
|
+
if (estimatedMemoryMB > ESTIMATED_MEMORY_WARNING_MB) {
|
|
1102
|
+
Log.w(TAG, "Page $pageIndex: Large bitmap ${width}x${height} (~${estimatedMemoryMB}MB)")
|
|
954
1103
|
}
|
|
955
|
-
|
|
956
|
-
if (!bitmap.isRecycled) bitmap.recycle()
|
|
957
|
-
|
|
958
|
-
return "file://${file.absolutePath}"
|
|
959
1104
|
}
|
|
960
1105
|
|
|
961
|
-
private fun
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
} catch (e: Exception) {
|
|
969
|
-
"unknown"
|
|
1106
|
+
private suspend fun handleOutOfMemoryError(pageIndex: Int, error: OutOfMemoryError) {
|
|
1107
|
+
Log.e(TAG, "OOM rendering page $pageIndex at scale $currentScale", error)
|
|
1108
|
+
bitmapCache.evictAll()
|
|
1109
|
+
System.gc()
|
|
1110
|
+
|
|
1111
|
+
withContext(Dispatchers.Main) {
|
|
1112
|
+
emitError("Out of memory at zoom level ${currentScale}x. Try reducing zoom.", "OOM_ERROR")
|
|
970
1113
|
}
|
|
971
1114
|
}
|
|
972
1115
|
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
if (cacheDir.exists() && cacheDir.isDirectory) {
|
|
977
|
-
cacheDir.listFiles()?.forEach { file ->
|
|
978
|
-
try {
|
|
979
|
-
file.delete()
|
|
980
|
-
} catch (e: Exception) {
|
|
981
|
-
Log.e(TAG, "Error deleting thumbnail file: ${file.name}", e)
|
|
982
|
-
}
|
|
983
|
-
}
|
|
984
|
-
}
|
|
985
|
-
} catch (e: Exception) {
|
|
986
|
-
Log.e(TAG, "Error cleaning up thumbnail directory", e)
|
|
987
|
-
}
|
|
988
|
-
}
|
|
1116
|
+
// endregion
|
|
1117
|
+
|
|
1118
|
+
// region RecyclerView Adapter
|
|
989
1119
|
|
|
990
|
-
inner class PdfPageAdapter : RecyclerView.Adapter<PdfPageViewHolder>() {
|
|
1120
|
+
private inner class PdfPageAdapter : RecyclerView.Adapter<PdfPageViewHolder>() {
|
|
1121
|
+
|
|
991
1122
|
override fun getItemCount(): Int = pdfRenderer?.pageCount ?: 0
|
|
992
1123
|
|
|
993
1124
|
override fun onCreateViewHolder(parent: android.view.ViewGroup, viewType: Int): PdfPageViewHolder {
|
|
@@ -1000,38 +1131,46 @@ class PdfViewer(context: Context) : FrameLayout(context) {
|
|
|
1000
1131
|
}
|
|
1001
1132
|
|
|
1002
1133
|
override fun onBindViewHolder(holder: PdfPageViewHolder, position: Int) {
|
|
1003
|
-
|
|
1134
|
+
bindPageDimensions(holder, position)
|
|
1135
|
+
bindPageBitmap(holder, position)
|
|
1136
|
+
}
|
|
1137
|
+
|
|
1138
|
+
private fun bindPageDimensions(holder: PdfPageViewHolder, position: Int) {
|
|
1004
1139
|
val dimensions = pageDimensions[position]
|
|
1140
|
+
|
|
1005
1141
|
if (dimensions != null && viewWidth > 0) {
|
|
1006
|
-
|
|
1007
|
-
val
|
|
1142
|
+
// Scale to fit width (no zoom factor - zoom is handled by view transformation)
|
|
1143
|
+
val scale = viewWidth.toFloat() / dimensions.width
|
|
1144
|
+
val targetHeight = (dimensions.height * scale).toInt().coerceAtLeast(100)
|
|
1008
1145
|
holder.imageView.layoutParams.height = targetHeight
|
|
1009
1146
|
} else {
|
|
1010
|
-
// Dimensions not loaded yet, use default height and trigger lazy load
|
|
1011
1147
|
holder.imageView.layoutParams.height = (viewWidth * 1.414f).toInt().coerceAtLeast(100)
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1148
|
+
loadDimensionsAsync(holder, position)
|
|
1149
|
+
}
|
|
1150
|
+
}
|
|
1151
|
+
|
|
1152
|
+
private fun loadDimensionsAsync(holder: PdfPageViewHolder, position: Int) {
|
|
1153
|
+
componentScope.launch(Dispatchers.IO) {
|
|
1154
|
+
val dims = getPageDimensions(position)
|
|
1155
|
+
withContext(Dispatchers.Main) {
|
|
1156
|
+
if (holder.bindingAdapterPosition == position) {
|
|
1157
|
+
val scale = viewWidth.toFloat() / dims.width
|
|
1158
|
+
val targetHeight = (dims.height * scale).toInt().coerceAtLeast(100)
|
|
1159
|
+
holder.imageView.layoutParams.height = targetHeight
|
|
1160
|
+
holder.imageView.requestLayout()
|
|
1024
1161
|
}
|
|
1025
1162
|
}
|
|
1026
1163
|
}
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1164
|
+
}
|
|
1165
|
+
|
|
1166
|
+
private fun bindPageBitmap(holder: PdfPageViewHolder, position: Int) {
|
|
1167
|
+
// Cache key no longer includes scale since bitmap is rendered at fixed quality
|
|
1168
|
+
val cacheKey = "$position"
|
|
1030
1169
|
val cached = bitmapCache.get(cacheKey)
|
|
1170
|
+
|
|
1031
1171
|
if (cached != null && !cached.isRecycled) {
|
|
1032
1172
|
holder.imageView.setImageBitmap(cached)
|
|
1033
1173
|
} else {
|
|
1034
|
-
// Show placeholder and trigger render
|
|
1035
1174
|
holder.imageView.setImageBitmap(createPlaceholderBitmap(position))
|
|
1036
1175
|
startPageRender(position)
|
|
1037
1176
|
}
|
|
@@ -1039,131 +1178,133 @@ class PdfViewer(context: Context) : FrameLayout(context) {
|
|
|
1039
1178
|
|
|
1040
1179
|
override fun onViewRecycled(holder: PdfPageViewHolder) {
|
|
1041
1180
|
super.onViewRecycled(holder)
|
|
1042
|
-
// Clear reference but don't recycle the bitmap (cache may still have it)
|
|
1043
1181
|
holder.imageView.setImageBitmap(null)
|
|
1044
1182
|
}
|
|
1045
1183
|
|
|
1046
1184
|
private fun createPlaceholderBitmap(pageIndex: Int): Bitmap {
|
|
1047
1185
|
val dimensions = pageDimensions[pageIndex]
|
|
1048
|
-
val w = dimensions?.let { (it.
|
|
1049
|
-
val h = dimensions?.let { (it.
|
|
1186
|
+
val w = dimensions?.let { (it.width * 0.1f).toInt() } ?: 100
|
|
1187
|
+
val h = dimensions?.let { (it.height * 0.1f).toInt() } ?: 100
|
|
1050
1188
|
|
|
1051
|
-
return Bitmap.createBitmap(
|
|
1189
|
+
return Bitmap.createBitmap(
|
|
1190
|
+
w.coerceAtLeast(10),
|
|
1191
|
+
h.coerceAtLeast(10),
|
|
1192
|
+
Bitmap.Config.ARGB_8888
|
|
1193
|
+
).apply {
|
|
1052
1194
|
Canvas(this).drawColor(Color.WHITE)
|
|
1053
1195
|
}
|
|
1054
1196
|
}
|
|
1055
1197
|
}
|
|
1056
1198
|
|
|
1057
|
-
inner class PdfPageViewHolder(val imageView: ImageView) : RecyclerView.ViewHolder(imageView)
|
|
1199
|
+
private inner class PdfPageViewHolder(val imageView: ImageView) : RecyclerView.ViewHolder(imageView)
|
|
1200
|
+
|
|
1201
|
+
// endregion
|
|
1058
1202
|
|
|
1059
|
-
|
|
1203
|
+
// region Gesture Listeners
|
|
1204
|
+
|
|
1205
|
+
private inner class ScaleListener : ScaleGestureDetector.SimpleOnScaleGestureListener() {
|
|
1206
|
+
private var lastFocusX = 0f
|
|
1207
|
+
private var lastFocusY = 0f
|
|
1208
|
+
|
|
1209
|
+
override fun onScaleBegin(detector: ScaleGestureDetector): Boolean {
|
|
1210
|
+
if (!enableZoom) return false
|
|
1211
|
+
lastFocusX = detector.focusX
|
|
1212
|
+
lastFocusY = detector.focusY
|
|
1213
|
+
return true
|
|
1214
|
+
}
|
|
1215
|
+
|
|
1060
1216
|
override fun onScale(detector: ScaleGestureDetector): Boolean {
|
|
1061
1217
|
if (!enableZoom) return false
|
|
1062
1218
|
|
|
1063
1219
|
val newScale = (currentScale * detector.scaleFactor).coerceIn(minScale, maxScale)
|
|
1064
|
-
if (newScale
|
|
1065
|
-
setScale(newScale)
|
|
1220
|
+
if (kotlin.math.abs(newScale - currentScale) > 0.01f) {
|
|
1221
|
+
setScale(newScale, detector.focusX, detector.focusY)
|
|
1066
1222
|
}
|
|
1067
1223
|
return true
|
|
1068
1224
|
}
|
|
1069
1225
|
|
|
1070
|
-
override fun
|
|
1226
|
+
override fun onScaleEnd(detector: ScaleGestureDetector) {
|
|
1227
|
+
// Snap to 1.0 if very close
|
|
1228
|
+
if (currentScale < 1.05f && currentScale > 0.95f) {
|
|
1229
|
+
resetZoomTransform()
|
|
1230
|
+
emitScaleChange(1.0f)
|
|
1231
|
+
}
|
|
1232
|
+
}
|
|
1071
1233
|
}
|
|
1072
1234
|
|
|
1073
|
-
inner class GestureListener : GestureDetector.SimpleOnGestureListener() {
|
|
1235
|
+
private inner class GestureListener : GestureDetector.SimpleOnGestureListener() {
|
|
1074
1236
|
override fun onDoubleTap(e: MotionEvent): Boolean {
|
|
1075
1237
|
if (!enableZoom) return false
|
|
1076
1238
|
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1239
|
+
if (currentScale > 1.1f) {
|
|
1240
|
+
// Zoom out to 1.0
|
|
1241
|
+
resetZoomTransform()
|
|
1242
|
+
emitScaleChange(1.0f)
|
|
1243
|
+
} else {
|
|
1244
|
+
// Zoom in to 2.0 centered on tap point
|
|
1245
|
+
setScale(2.0f, e.x, e.y)
|
|
1246
|
+
}
|
|
1080
1247
|
return true
|
|
1081
1248
|
}
|
|
1082
1249
|
}
|
|
1083
1250
|
|
|
1084
|
-
//
|
|
1251
|
+
// endregion
|
|
1252
|
+
|
|
1253
|
+
// region Event Emitters
|
|
1254
|
+
|
|
1085
1255
|
private fun emitLoadComplete(pageCount: Int, pageWidth: Int, pageHeight: Int) {
|
|
1086
|
-
// Call Nitro callback
|
|
1087
1256
|
onLoadCompleteCallback?.invoke(pageCount, pageWidth, pageHeight)
|
|
1088
1257
|
}
|
|
1089
1258
|
|
|
1090
1259
|
private fun emitPageChange(page: Int) {
|
|
1091
1260
|
val pageCount = pdfRenderer?.pageCount ?: return
|
|
1092
|
-
|
|
1093
|
-
// Call Nitro callback
|
|
1094
|
-
onPageChangeCallback?.invoke(page, pageCount)
|
|
1261
|
+
onPageChangeCallback?.invoke(page + 1, pageCount)
|
|
1095
1262
|
}
|
|
1096
1263
|
|
|
1097
1264
|
private fun emitScaleChange(scale: Float) {
|
|
1098
|
-
// Call Nitro callback
|
|
1099
1265
|
onScaleChangeCallback?.invoke(scale)
|
|
1100
1266
|
}
|
|
1101
1267
|
|
|
1102
1268
|
private fun emitError(message: String, code: String) {
|
|
1103
|
-
// Call Nitro callback
|
|
1104
1269
|
onErrorCallback?.invoke(message, code)
|
|
1105
1270
|
}
|
|
1106
1271
|
|
|
1107
1272
|
private fun emitThumbnailGenerated(page: Int, uri: String) {
|
|
1108
|
-
// Call Nitro callback
|
|
1109
1273
|
onThumbnailGeneratedCallback?.invoke(page, uri)
|
|
1110
1274
|
}
|
|
1111
1275
|
|
|
1112
1276
|
private fun emitLoadingChange(loading: Boolean) {
|
|
1113
|
-
// Call Nitro callback
|
|
1114
1277
|
onLoadingChangeCallback?.invoke(loading)
|
|
1115
1278
|
}
|
|
1116
1279
|
|
|
1280
|
+
// endregion
|
|
1281
|
+
|
|
1282
|
+
// region Lifecycle
|
|
1283
|
+
|
|
1117
1284
|
override fun onDetachedFromWindow() {
|
|
1118
1285
|
super.onDetachedFromWindow()
|
|
1119
1286
|
|
|
1120
|
-
|
|
1287
|
+
ProcessLifecycleOwner.get().lifecycle.removeObserver(lifecycleObserver)
|
|
1121
1288
|
componentScope.cancel()
|
|
1122
1289
|
cancelAllRenderJobs()
|
|
1123
1290
|
|
|
1124
|
-
|
|
1125
|
-
post {
|
|
1126
|
-
val snapshot = bitmapCache.snapshot()
|
|
1127
|
-
bitmapCache.evictAll()
|
|
1128
|
-
|
|
1129
|
-
// Now safe to recycle - views are detached
|
|
1130
|
-
snapshot.values.forEach { bitmap ->
|
|
1131
|
-
if (!bitmap.isRecycled) {
|
|
1132
|
-
try {
|
|
1133
|
-
bitmap.recycle()
|
|
1134
|
-
} catch (e: Exception) {
|
|
1135
|
-
Log.e(TAG, "Error recycling bitmap", e)
|
|
1136
|
-
}
|
|
1137
|
-
}
|
|
1138
|
-
}
|
|
1139
|
-
}
|
|
1291
|
+
post { recycleCachedBitmaps() }
|
|
1140
1292
|
|
|
1141
1293
|
closePdfRenderer()
|
|
1142
1294
|
pageDimensions.clear()
|
|
1143
1295
|
thumbnailCache.clear()
|
|
1144
1296
|
pendingThumbnails.clear()
|
|
1145
|
-
|
|
1146
|
-
// Clean up thumbnail directory
|
|
1147
|
-
cleanupThumbnailDirectory()
|
|
1148
1297
|
}
|
|
1149
1298
|
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
throw e
|
|
1158
|
-
} finally {
|
|
1159
|
-
when {
|
|
1160
|
-
exception == null -> close()
|
|
1161
|
-
else -> try {
|
|
1162
|
-
close()
|
|
1163
|
-
} catch (closeException: Throwable) {
|
|
1164
|
-
exception.addSuppressed(closeException)
|
|
1165
|
-
}
|
|
1166
|
-
}
|
|
1299
|
+
private fun recycleCachedBitmaps() {
|
|
1300
|
+
val snapshot = bitmapCache.snapshot()
|
|
1301
|
+
bitmapCache.evictAll()
|
|
1302
|
+
|
|
1303
|
+
snapshot.values.forEach { bitmap ->
|
|
1304
|
+
runCatching { if (!bitmap.isRecycled) bitmap.recycle() }
|
|
1305
|
+
.onFailure { Log.e(TAG, "Error recycling bitmap", it) }
|
|
1167
1306
|
}
|
|
1168
1307
|
}
|
|
1308
|
+
|
|
1309
|
+
// endregion
|
|
1169
1310
|
}
|