@thatkid02/react-native-pdf-viewer 0.0.1

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.
Files changed (95) hide show
  1. package/LICENSE +20 -0
  2. package/PdfViewer.podspec +28 -0
  3. package/README.md +290 -0
  4. package/android/CMakeLists.txt +24 -0
  5. package/android/build.gradle +121 -0
  6. package/android/src/main/AndroidManifest.xml +2 -0
  7. package/android/src/main/cpp/cpp-adapter.cpp +6 -0
  8. package/android/src/main/java/com/margelo/nitro/pdfviewer/HybridPdfViewer.kt +169 -0
  9. package/android/src/main/java/com/margelo/nitro/pdfviewer/PdfViewer.kt +996 -0
  10. package/android/src/main/java/com/margelo/nitro/pdfviewer/PdfViewerPackage.kt +26 -0
  11. package/ios/PdfViewer.swift +696 -0
  12. package/lib/module/PdfViewer.nitro.js +4 -0
  13. package/lib/module/PdfViewer.nitro.js.map +1 -0
  14. package/lib/module/index.js +13 -0
  15. package/lib/module/index.js.map +1 -0
  16. package/lib/module/package.json +1 -0
  17. package/lib/typescript/package.json +1 -0
  18. package/lib/typescript/src/PdfViewer.nitro.d.ts +67 -0
  19. package/lib/typescript/src/PdfViewer.nitro.d.ts.map +1 -0
  20. package/lib/typescript/src/index.d.ts +8 -0
  21. package/lib/typescript/src/index.d.ts.map +1 -0
  22. package/nitro.json +17 -0
  23. package/nitrogen/generated/android/c++/JErrorEvent.hpp +57 -0
  24. package/nitrogen/generated/android/c++/JFunc_void_ErrorEvent.hpp +77 -0
  25. package/nitrogen/generated/android/c++/JFunc_void_LoadCompleteEvent.hpp +76 -0
  26. package/nitrogen/generated/android/c++/JFunc_void_LoadingChangeEvent.hpp +76 -0
  27. package/nitrogen/generated/android/c++/JFunc_void_PageChangeEvent.hpp +76 -0
  28. package/nitrogen/generated/android/c++/JFunc_void_ScaleChangeEvent.hpp +76 -0
  29. package/nitrogen/generated/android/c++/JFunc_void_ThumbnailGeneratedEvent.hpp +77 -0
  30. package/nitrogen/generated/android/c++/JHybridPdfViewerSpec.cpp +273 -0
  31. package/nitrogen/generated/android/c++/JHybridPdfViewerSpec.hpp +94 -0
  32. package/nitrogen/generated/android/c++/JLoadCompleteEvent.hpp +61 -0
  33. package/nitrogen/generated/android/c++/JLoadingChangeEvent.hpp +53 -0
  34. package/nitrogen/generated/android/c++/JPageChangeEvent.hpp +57 -0
  35. package/nitrogen/generated/android/c++/JScaleChangeEvent.hpp +53 -0
  36. package/nitrogen/generated/android/c++/JThumbnailGeneratedEvent.hpp +57 -0
  37. package/nitrogen/generated/android/c++/views/JHybridPdfViewerStateUpdater.cpp +108 -0
  38. package/nitrogen/generated/android/c++/views/JHybridPdfViewerStateUpdater.hpp +49 -0
  39. package/nitrogen/generated/android/kotlin/com/margelo/nitro/pdfviewer/ErrorEvent.kt +32 -0
  40. package/nitrogen/generated/android/kotlin/com/margelo/nitro/pdfviewer/Func_void_ErrorEvent.kt +81 -0
  41. package/nitrogen/generated/android/kotlin/com/margelo/nitro/pdfviewer/Func_void_LoadCompleteEvent.kt +81 -0
  42. package/nitrogen/generated/android/kotlin/com/margelo/nitro/pdfviewer/Func_void_LoadingChangeEvent.kt +81 -0
  43. package/nitrogen/generated/android/kotlin/com/margelo/nitro/pdfviewer/Func_void_PageChangeEvent.kt +81 -0
  44. package/nitrogen/generated/android/kotlin/com/margelo/nitro/pdfviewer/Func_void_ScaleChangeEvent.kt +81 -0
  45. package/nitrogen/generated/android/kotlin/com/margelo/nitro/pdfviewer/Func_void_ThumbnailGeneratedEvent.kt +81 -0
  46. package/nitrogen/generated/android/kotlin/com/margelo/nitro/pdfviewer/HybridPdfViewerSpec.kt +195 -0
  47. package/nitrogen/generated/android/kotlin/com/margelo/nitro/pdfviewer/LoadCompleteEvent.kt +35 -0
  48. package/nitrogen/generated/android/kotlin/com/margelo/nitro/pdfviewer/LoadingChangeEvent.kt +29 -0
  49. package/nitrogen/generated/android/kotlin/com/margelo/nitro/pdfviewer/PageChangeEvent.kt +32 -0
  50. package/nitrogen/generated/android/kotlin/com/margelo/nitro/pdfviewer/ScaleChangeEvent.kt +29 -0
  51. package/nitrogen/generated/android/kotlin/com/margelo/nitro/pdfviewer/ThumbnailGeneratedEvent.kt +32 -0
  52. package/nitrogen/generated/android/kotlin/com/margelo/nitro/pdfviewer/pdfviewerOnLoad.kt +35 -0
  53. package/nitrogen/generated/android/kotlin/com/margelo/nitro/pdfviewer/views/HybridPdfViewerManager.kt +50 -0
  54. package/nitrogen/generated/android/kotlin/com/margelo/nitro/pdfviewer/views/HybridPdfViewerStateUpdater.kt +23 -0
  55. package/nitrogen/generated/android/pdfviewer+autolinking.cmake +83 -0
  56. package/nitrogen/generated/android/pdfviewer+autolinking.gradle +27 -0
  57. package/nitrogen/generated/android/pdfviewerOnLoad.cpp +58 -0
  58. package/nitrogen/generated/android/pdfviewerOnLoad.hpp +25 -0
  59. package/nitrogen/generated/ios/PdfViewer+autolinking.rb +60 -0
  60. package/nitrogen/generated/ios/PdfViewer-Swift-Cxx-Bridge.cpp +80 -0
  61. package/nitrogen/generated/ios/PdfViewer-Swift-Cxx-Bridge.hpp +339 -0
  62. package/nitrogen/generated/ios/PdfViewer-Swift-Cxx-Umbrella.hpp +64 -0
  63. package/nitrogen/generated/ios/PdfViewerAutolinking.mm +33 -0
  64. package/nitrogen/generated/ios/PdfViewerAutolinking.swift +25 -0
  65. package/nitrogen/generated/ios/c++/HybridPdfViewerSpecSwift.cpp +11 -0
  66. package/nitrogen/generated/ios/c++/HybridPdfViewerSpecSwift.hpp +205 -0
  67. package/nitrogen/generated/ios/c++/views/HybridPdfViewerComponent.mm +161 -0
  68. package/nitrogen/generated/ios/swift/ErrorEvent.swift +46 -0
  69. package/nitrogen/generated/ios/swift/Func_void_ErrorEvent.swift +47 -0
  70. package/nitrogen/generated/ios/swift/Func_void_LoadCompleteEvent.swift +47 -0
  71. package/nitrogen/generated/ios/swift/Func_void_LoadingChangeEvent.swift +47 -0
  72. package/nitrogen/generated/ios/swift/Func_void_PageChangeEvent.swift +47 -0
  73. package/nitrogen/generated/ios/swift/Func_void_ScaleChangeEvent.swift +47 -0
  74. package/nitrogen/generated/ios/swift/Func_void_ThumbnailGeneratedEvent.swift +47 -0
  75. package/nitrogen/generated/ios/swift/HybridPdfViewerSpec.swift +65 -0
  76. package/nitrogen/generated/ios/swift/HybridPdfViewerSpec_cxx.swift +500 -0
  77. package/nitrogen/generated/ios/swift/LoadCompleteEvent.swift +57 -0
  78. package/nitrogen/generated/ios/swift/LoadingChangeEvent.swift +35 -0
  79. package/nitrogen/generated/ios/swift/PageChangeEvent.swift +46 -0
  80. package/nitrogen/generated/ios/swift/ScaleChangeEvent.swift +35 -0
  81. package/nitrogen/generated/ios/swift/ThumbnailGeneratedEvent.swift +46 -0
  82. package/nitrogen/generated/shared/c++/ErrorEvent.hpp +71 -0
  83. package/nitrogen/generated/shared/c++/HybridPdfViewerSpec.cpp +52 -0
  84. package/nitrogen/generated/shared/c++/HybridPdfViewerSpec.hpp +111 -0
  85. package/nitrogen/generated/shared/c++/LoadCompleteEvent.hpp +75 -0
  86. package/nitrogen/generated/shared/c++/LoadingChangeEvent.hpp +67 -0
  87. package/nitrogen/generated/shared/c++/PageChangeEvent.hpp +71 -0
  88. package/nitrogen/generated/shared/c++/ScaleChangeEvent.hpp +67 -0
  89. package/nitrogen/generated/shared/c++/ThumbnailGeneratedEvent.hpp +71 -0
  90. package/nitrogen/generated/shared/c++/views/HybridPdfViewerComponent.cpp +243 -0
  91. package/nitrogen/generated/shared/c++/views/HybridPdfViewerComponent.hpp +127 -0
  92. package/nitrogen/generated/shared/json/PdfViewerConfig.json +23 -0
  93. package/package.json +175 -0
  94. package/src/PdfViewer.nitro.ts +97 -0
  95. package/src/index.tsx +27 -0
@@ -0,0 +1,996 @@
1
+ package com.margelo.nitro.pdfviewer
2
+
3
+ import android.content.Context
4
+ import android.graphics.Bitmap
5
+ import android.graphics.Canvas
6
+ import android.graphics.Color
7
+ import android.graphics.pdf.PdfRenderer
8
+ import android.os.ParcelFileDescriptor
9
+ import android.util.Log
10
+ import android.util.LruCache
11
+ import android.view.Choreographer
12
+ import android.view.GestureDetector
13
+ import android.view.MotionEvent
14
+ import android.view.ScaleGestureDetector
15
+ import android.view.View
16
+ import android.graphics.Rect
17
+ import android.widget.FrameLayout
18
+ import android.widget.ImageView
19
+ import android.widget.ProgressBar
20
+ import androidx.core.view.doOnLayout
21
+ import androidx.recyclerview.widget.LinearLayoutManager
22
+ import androidx.recyclerview.widget.RecyclerView
23
+ import com.facebook.proguard.annotations.DoNotStrip
24
+ import kotlinx.coroutines.CoroutineScope
25
+ import kotlinx.coroutines.Dispatchers
26
+ import kotlinx.coroutines.Job
27
+ import kotlinx.coroutines.SupervisorJob
28
+ import kotlinx.coroutines.cancel
29
+ import kotlinx.coroutines.launch
30
+ import kotlinx.coroutines.sync.Mutex
31
+ import kotlinx.coroutines.sync.withLock
32
+ import kotlinx.coroutines.withContext
33
+ import java.io.File
34
+ import java.io.FileOutputStream
35
+ import java.net.URL
36
+ import java.security.MessageDigest
37
+ import kotlin.math.roundToInt
38
+
39
+ @DoNotStrip
40
+ class PdfViewer(context: Context) : FrameLayout(context) {
41
+ companion object {
42
+ private const val TAG = "PdfViewer"
43
+ 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
+ private const val MAX_BITMAP_DIMENSION = 4096
49
+ // Threshold for using reduced quality
50
+ private const val HIGH_ZOOM_THRESHOLD = 1.5f
51
+ }
52
+
53
+ // Core PDF components
54
+ private var pdfRenderer: PdfRenderer? = null
55
+ private var parcelFileDescriptor: ParcelFileDescriptor? = null
56
+ private val renderMutex = Mutex()
57
+
58
+ // Coroutine scope with SupervisorJob for error isolation
59
+ private val componentScope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
60
+
61
+ // UI components
62
+ private val recyclerView: RecyclerView
63
+ private val loadingIndicator: ProgressBar
64
+ private var adapter: PdfPageAdapter? = null
65
+
66
+ // Gesture handling
67
+ private val scaleGestureDetector: ScaleGestureDetector
68
+ private val gestureDetector: GestureDetector
69
+ private var isScaling = false
70
+
71
+ // Rendering state
72
+ private val activeRenderJobs = mutableMapOf<Int, Job>()
73
+ private val bitmapCache: LruCache<String, Bitmap>
74
+ private val pageDimensions = mutableMapOf<Int, Pair<Int, Int>>()
75
+ private val thumbnailCache = java.util.concurrent.ConcurrentHashMap<Int, String>()
76
+ private val pendingThumbnails = java.util.concurrent.ConcurrentHashMap.newKeySet<Int>()
77
+ private var currentLoadJob: Job? = null
78
+
79
+ // Props
80
+ private var _sourceUri: String? = null
81
+ var sourceUri: String?
82
+ get() = _sourceUri
83
+ set(value) {
84
+ if (_sourceUri != value) {
85
+ _sourceUri = value
86
+ value?.let { loadDocument(it) }
87
+ }
88
+ }
89
+
90
+ var showsActivityIndicator: Boolean = true
91
+ set(value) {
92
+ if (field != value) {
93
+ field = value
94
+ updateLoadingIndicator()
95
+ }
96
+ }
97
+
98
+ // Note: horizontal and enablePaging are iOS-only features
99
+ // Android always uses vertical scroll
100
+ var spacing: Float = 8f
101
+ var enableZoom: Boolean = true
102
+ var minScale: Float = 0.5f
103
+ var maxScale: Float = 4.0f
104
+
105
+ // Callbacks for HybridPdfViewer integration
106
+ var onLoadCompleteCallback: ((pageCount: Int, pageWidth: Int, pageHeight: Int) -> Unit)? = null
107
+ var onPageChangeCallback: ((page: Int, pageCount: Int) -> Unit)? = null
108
+ var onScaleChangeCallback: ((scale: Float) -> Unit)? = null
109
+ var onErrorCallback: ((message: String, code: String) -> Unit)? = null
110
+ var onThumbnailGeneratedCallback: ((page: Int, uri: String) -> Unit)? = null
111
+ var onLoadingChangeCallback: ((isLoading: Boolean) -> Unit)? = null
112
+
113
+ // Runtime state
114
+ private var currentScale = 1.0f
115
+ private var lastReportedPage = -1
116
+ private var isLoading = false
117
+ private var viewWidth = 0
118
+
119
+ init {
120
+ recyclerView = RecyclerView(context).apply {
121
+ layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)
122
+ setHasFixedSize(false)
123
+ setItemViewCacheSize(4)
124
+ recycledViewPool.setMaxRecycledViews(0, 12)
125
+
126
+ // Allow parent to intercept touch events for pinch-to-zoom
127
+ requestDisallowInterceptTouchEvent(false)
128
+ }
129
+
130
+ loadingIndicator = ProgressBar(context).apply {
131
+ val size = (48 * context.resources.displayMetrics.density).toInt()
132
+ layoutParams = LayoutParams(size, size).apply {
133
+ gravity = android.view.Gravity.CENTER
134
+ }
135
+ isIndeterminate = true
136
+ visibility = View.GONE
137
+ }
138
+
139
+ scaleGestureDetector = ScaleGestureDetector(context, ScaleListener())
140
+ gestureDetector = GestureDetector(context, GestureListener())
141
+
142
+ // Initialize bitmap cache (25% of available memory)
143
+ val maxMemory = (Runtime.getRuntime().maxMemory() / 1024).toInt()
144
+ val cacheSize = (maxMemory * CACHE_SIZE_PERCENTAGE).toInt()
145
+
146
+ bitmapCache = object : LruCache<String, Bitmap>(cacheSize) {
147
+ override fun sizeOf(key: String, bitmap: Bitmap): Int = bitmap.byteCount / 1024
148
+
149
+ override fun entryRemoved(evicted: Boolean, key: String, oldValue: Bitmap?, newValue: Bitmap?) {
150
+ // CRITICAL: Don't recycle bitmaps - RecyclerView may still be using them
151
+ // Let GC handle cleanup. Only recycle on component unmount.
152
+ }
153
+ }
154
+
155
+ setupRecyclerView()
156
+ applySpacing()
157
+
158
+ addView(recyclerView)
159
+ addView(loadingIndicator)
160
+
161
+ // Measure view width for proper rendering
162
+ doOnLayout {
163
+ val newWidth = width
164
+ if (newWidth > 0 && newWidth != viewWidth) {
165
+ viewWidth = newWidth
166
+ adapter?.notifyDataSetChanged()
167
+ }
168
+ }
169
+ }
170
+
171
+ override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) {
172
+ super.onLayout(changed, left, top, right, bottom)
173
+
174
+ // Update viewWidth whenever layout changes
175
+ val newWidth = right - left
176
+ if (newWidth > 0 && newWidth != viewWidth) {
177
+ viewWidth = newWidth
178
+ post { adapter?.notifyDataSetChanged() }
179
+ }
180
+ }
181
+
182
+ private fun applySpacing() {
183
+ // Add spacing between PDF pages
184
+ recyclerView.addItemDecoration(object : RecyclerView.ItemDecoration() {
185
+ override fun getItemOffsets(outRect: Rect, view: View, parent: RecyclerView, state: RecyclerView.State) {
186
+ val position = parent.getChildAdapterPosition(view)
187
+ if (position != RecyclerView.NO_POSITION && position > 0) {
188
+ outRect.top = spacing.toInt()
189
+ }
190
+ }
191
+ })
192
+ }
193
+
194
+ private fun setupRecyclerView() {
195
+ // Android always uses vertical scroll (horizontal is iOS-only)
196
+ val layoutManager = LinearLayoutManager(context, LinearLayoutManager.VERTICAL, false)
197
+ recyclerView.layoutManager = layoutManager
198
+
199
+ recyclerView.addOnScrollListener(object : RecyclerView.OnScrollListener() {
200
+ private var scrollState = RecyclerView.SCROLL_STATE_IDLE
201
+
202
+ override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
203
+ super.onScrolled(recyclerView, dx, dy)
204
+
205
+ val layoutManager = recyclerView.layoutManager as? LinearLayoutManager ?: return
206
+ val firstVisible = layoutManager.findFirstVisibleItemPosition()
207
+
208
+ if (firstVisible >= 0 && firstVisible != lastReportedPage) {
209
+ lastReportedPage = firstVisible
210
+ emitPageChange(firstVisible)
211
+ }
212
+
213
+ if (scrollState == RecyclerView.SCROLL_STATE_IDLE) {
214
+ schedulePreload()
215
+ }
216
+ }
217
+
218
+ override fun onScrollStateChanged(recyclerView: RecyclerView, newState: Int) {
219
+ super.onScrollStateChanged(recyclerView, newState)
220
+ scrollState = newState
221
+
222
+ if (newState == RecyclerView.SCROLL_STATE_IDLE) {
223
+ schedulePreload()
224
+ }
225
+ }
226
+ })
227
+ }
228
+
229
+ override fun onTouchEvent(event: MotionEvent): Boolean {
230
+ var handled = false
231
+
232
+ if (enableZoom) {
233
+ // Try double-tap first (gestureDetector)
234
+ gestureDetector.onTouchEvent(event)
235
+
236
+ // Then try scale gesture detector
237
+ scaleGestureDetector.onTouchEvent(event)
238
+ handled = scaleGestureDetector.isInProgress
239
+ isScaling = scaleGestureDetector.isInProgress
240
+
241
+ if (isScaling) {
242
+ // During scaling, request not to be intercepted
243
+ parent?.requestDisallowInterceptTouchEvent(true)
244
+ recyclerView.requestDisallowInterceptTouchEvent(true)
245
+ }
246
+ }
247
+
248
+ if (event.action == MotionEvent.ACTION_UP || event.action == MotionEvent.ACTION_CANCEL) {
249
+ isScaling = false
250
+ parent?.requestDisallowInterceptTouchEvent(false)
251
+ recyclerView.requestDisallowInterceptTouchEvent(false)
252
+ }
253
+
254
+ // Pass to children if not scaling
255
+ if (!handled) {
256
+ handled = recyclerView.onTouchEvent(event)
257
+ }
258
+
259
+ return handled || super.onTouchEvent(event)
260
+ }
261
+
262
+ override fun onInterceptTouchEvent(event: MotionEvent): Boolean {
263
+ // Check for pinch gesture
264
+ if (enableZoom) {
265
+ scaleGestureDetector.onTouchEvent(event)
266
+ if (scaleGestureDetector.isInProgress) {
267
+ return true
268
+ }
269
+ }
270
+ return super.onInterceptTouchEvent(event)
271
+ }
272
+
273
+ fun loadDocument(uri: String?) {
274
+ if (BuildConfig.DEBUG) {
275
+ Log.d(TAG, "loadDocument called with uri: $uri")
276
+ }
277
+ if (uri.isNullOrBlank()) {
278
+ Log.e(TAG, "URI is null or blank")
279
+ emitError("URI cannot be empty", "INVALID_URI")
280
+ return
281
+ }
282
+
283
+ // Cancel previous load if in progress
284
+ if (isLoading) {
285
+ if (BuildConfig.DEBUG) {
286
+ Log.w(TAG, "Canceling previous document load")
287
+ }
288
+ currentLoadJob?.cancel()
289
+ currentLoadJob = null
290
+ }
291
+
292
+ isLoading = true
293
+ setLoadingState(true)
294
+ showLoading(true)
295
+ cancelAllRenderJobs()
296
+
297
+ // Clear cache without recycling (GC will handle it)
298
+ bitmapCache.evictAll()
299
+
300
+ currentLoadJob = componentScope.launch(Dispatchers.IO) {
301
+ try {
302
+ if (BuildConfig.DEBUG) {
303
+ Log.d(TAG, "Starting document download/load for uri: $uri")
304
+ }
305
+ val file = downloadOrGetFile(uri)
306
+ if (BuildConfig.DEBUG) {
307
+ Log.d(TAG, "File obtained: ${file.absolutePath}, exists: ${file.exists()}, canRead: ${file.canRead()}")
308
+ }
309
+
310
+ require(file.exists()) { "File does not exist: ${file.absolutePath}" }
311
+ require(file.canRead()) { "Cannot read file: ${file.absolutePath}" }
312
+
313
+ val fd = ParcelFileDescriptor.open(file, ParcelFileDescriptor.MODE_READ_ONLY)
314
+ val renderer = PdfRenderer(fd)
315
+ if (BuildConfig.DEBUG) {
316
+ Log.d(TAG, "PdfRenderer created successfully, pageCount: ${renderer.pageCount}")
317
+ }
318
+
319
+ require(renderer.pageCount > 0) { "PDF has no pages" }
320
+
321
+ withContext(Dispatchers.Main) {
322
+ closePdfRenderer()
323
+
324
+ pdfRenderer = renderer
325
+ parcelFileDescriptor = fd
326
+ bitmapCache.evictAll()
327
+ pageDimensions.clear()
328
+ thumbnailCache.clear()
329
+ pendingThumbnails.clear()
330
+ lastReportedPage = -1
331
+
332
+ // Preload page dimensions in background
333
+ componentScope.launch(Dispatchers.IO) {
334
+ preloadPageDimensions(renderer)
335
+ }
336
+
337
+ // Get first page dimensions immediately
338
+ val firstDim = try {
339
+ renderMutex.withLock {
340
+ renderer.openPage(0).use { page ->
341
+ Pair(page.width, page.height).also {
342
+ pageDimensions[0] = it
343
+ }
344
+ }
345
+ }
346
+ } catch (e: Exception) {
347
+ Log.e(TAG, "Error reading first page", e)
348
+ Pair(612, 792)
349
+ }
350
+
351
+ adapter = PdfPageAdapter()
352
+ recyclerView.adapter = adapter
353
+
354
+ showLoading(false)
355
+ setLoadingState(false)
356
+
357
+ if (BuildConfig.DEBUG) {
358
+ Log.d(TAG, "Emitting loadComplete: pageCount=${renderer.pageCount}, width=${firstDim.first}, height=${firstDim.second}")
359
+ }
360
+ emitLoadComplete(renderer.pageCount, firstDim.first, firstDim.second)
361
+
362
+ schedulePreload()
363
+ }
364
+ } catch (e: SecurityException) {
365
+ handleLoadError("PDF is password protected or encrypted", "SECURITY_ERROR", e)
366
+ } catch (e: java.io.FileNotFoundException) {
367
+ handleLoadError("File not found: ${e.message}", "FILE_NOT_FOUND", e)
368
+ } catch (e: java.io.IOException) {
369
+ handleLoadError("Failed to read PDF: ${e.message}", "IO_ERROR", e)
370
+ } catch (e: IllegalArgumentException) {
371
+ handleLoadError("Invalid or corrupted PDF: ${e.message}", "INVALID_FILE", e)
372
+ } catch (e: Exception) {
373
+ handleLoadError("Failed to load PDF: ${e.message}", "LOAD_FAILED", e)
374
+ } finally {
375
+ currentLoadJob = null
376
+ }
377
+ }
378
+ }
379
+
380
+ private suspend fun handleLoadError(message: String, code: String, error: Exception) {
381
+ Log.e(TAG, message, error)
382
+ withContext(Dispatchers.Main) {
383
+ showLoading(false)
384
+ setLoadingState(false)
385
+ emitError(message, code)
386
+ }
387
+ }
388
+
389
+ private suspend fun preloadPageDimensions(renderer: PdfRenderer) {
390
+ for (i in 0 until renderer.pageCount) {
391
+ try {
392
+ renderMutex.withLock {
393
+ renderer.openPage(i).use { page ->
394
+ pageDimensions[i] = Pair(page.width, page.height)
395
+ }
396
+ }
397
+ } catch (e: Exception) {
398
+ Log.e(TAG, "Error preloading page $i dimensions", e)
399
+ }
400
+ }
401
+ }
402
+
403
+ private fun closePdfRenderer() {
404
+ try {
405
+ pdfRenderer?.close()
406
+ } catch (e: Exception) {
407
+ Log.e(TAG, "Error closing renderer", e)
408
+ }
409
+ pdfRenderer = null
410
+
411
+ try {
412
+ parcelFileDescriptor?.close()
413
+ } catch (e: Exception) {
414
+ Log.e(TAG, "Error closing file descriptor", e)
415
+ }
416
+ parcelFileDescriptor = null
417
+ }
418
+
419
+ private fun showLoading(show: Boolean) {
420
+ loadingIndicator.visibility = if (show && showsActivityIndicator) View.VISIBLE else View.GONE
421
+ recyclerView.visibility = if (show) View.INVISIBLE else View.VISIBLE
422
+ }
423
+
424
+ private fun updateLoadingIndicator() {
425
+ loadingIndicator.visibility = if (isLoading && showsActivityIndicator) View.VISIBLE else View.GONE
426
+ }
427
+
428
+ private fun setLoadingState(loading: Boolean) {
429
+ if (isLoading != loading) {
430
+ isLoading = loading
431
+ emitLoadingChange(loading)
432
+ }
433
+ }
434
+
435
+ private fun cancelAllRenderJobs() {
436
+ synchronized(activeRenderJobs) {
437
+ activeRenderJobs.values.forEach { it.cancel() }
438
+ activeRenderJobs.clear()
439
+ }
440
+ }
441
+
442
+ private suspend fun downloadOrGetFile(uri: String): File = withContext(Dispatchers.IO) {
443
+ when {
444
+ uri.startsWith("file://") -> File(uri.substring(7))
445
+ uri.startsWith("http://") || uri.startsWith("https://") -> {
446
+ val hash = uri.hashCode().toString()
447
+ val file = File(context.cacheDir, "pdf_$hash.pdf")
448
+
449
+ if (!file.exists()) {
450
+ val connection = URL(uri).openConnection().apply {
451
+ connectTimeout = 30_000 // 30 seconds
452
+ readTimeout = 60_000 // 60 seconds
453
+ }
454
+ connection.getInputStream().use { input ->
455
+ FileOutputStream(file).use { output ->
456
+ input.copyTo(output)
457
+ }
458
+ }
459
+ }
460
+ file
461
+ }
462
+ else -> File(uri)
463
+ }
464
+ }
465
+
466
+ fun goToPage(page: Int) {
467
+ val renderer = pdfRenderer ?: run {
468
+ emitError("PDF not loaded", "NOT_LOADED")
469
+ return
470
+ }
471
+ if (page !in 0 until renderer.pageCount) {
472
+ emitError("Invalid page: $page. Valid range: 0-${renderer.pageCount - 1}", "INVALID_PAGE")
473
+ return
474
+ }
475
+
476
+ if (BuildConfig.DEBUG) {
477
+ Log.d(TAG, "goToPage called: $page")
478
+ }
479
+
480
+ post {
481
+ // Use smoothScrollToPosition for animated scroll
482
+ recyclerView.smoothScrollToPosition(page)
483
+
484
+ // Emit page change event for UI sync
485
+ emitPageChange(page)
486
+
487
+ postDelayed({ schedulePreload() }, 100)
488
+ }
489
+ }
490
+
491
+ fun setScale(scale: Float) {
492
+ if (!enableZoom) {
493
+ emitError("Zoom is disabled", "ZOOM_DISABLED")
494
+ return
495
+ }
496
+
497
+ val clampedScale = scale.coerceIn(minScale, maxScale)
498
+ if ((clampedScale - currentScale).let { it < 0.01f && it > -0.01f }) return
499
+
500
+ if (BuildConfig.DEBUG) {
501
+ Log.d(TAG, "setScale: $clampedScale (was $currentScale)")
502
+ }
503
+ currentScale = clampedScale
504
+
505
+ // Clear cache but DON'T recycle bitmaps (RecyclerView may still be drawing them)
506
+ bitmapCache.evictAll()
507
+
508
+ // Update adapter to refresh all views
509
+ adapter?.notifyDataSetChanged()
510
+ emitScaleChange(currentScale)
511
+
512
+ // Force immediate render of visible pages
513
+ post {
514
+ val layoutManager = recyclerView.layoutManager as? LinearLayoutManager
515
+ val firstVisible = layoutManager?.findFirstVisibleItemPosition() ?: 0
516
+ val lastVisible = layoutManager?.findLastVisibleItemPosition() ?: 0
517
+
518
+ for (i in firstVisible..lastVisible) {
519
+ startPageRender(i)
520
+ }
521
+
522
+ // Preload adjacent pages after a delay
523
+ postDelayed({ schedulePreload() }, 150)
524
+ }
525
+ }
526
+
527
+ fun generateThumbnail(page: Int) {
528
+ if (BuildConfig.DEBUG) {
529
+ Log.d(TAG, "generateThumbnail called for page: $page")
530
+ }
531
+ val renderer = pdfRenderer ?: run {
532
+ emitError("PDF not loaded", "NOT_LOADED")
533
+ return
534
+ }
535
+ if (page !in 0 until renderer.pageCount) {
536
+ emitError("Invalid page: $page. Valid range: 0-${renderer.pageCount - 1}", "INVALID_PAGE")
537
+ return
538
+ }
539
+
540
+ // Check cache first
541
+ thumbnailCache[page]?.let { cachedUri ->
542
+ if (BuildConfig.DEBUG) {
543
+ Log.d(TAG, "Thumbnail for page $page found in cache: $cachedUri")
544
+ }
545
+ emitThumbnailGenerated(page, cachedUri)
546
+ return
547
+ }
548
+
549
+ // Check if already being generated (thread-safe)
550
+ if (!pendingThumbnails.add(page)) {
551
+ if (BuildConfig.DEBUG) {
552
+ Log.d(TAG, "Thumbnail for page $page already being generated")
553
+ }
554
+ return
555
+ }
556
+
557
+ componentScope.launch(Dispatchers.IO) {
558
+ try {
559
+ if (BuildConfig.DEBUG) {
560
+ Log.d(TAG, "Starting thumbnail generation for page $page")
561
+ }
562
+ val thumbnail = renderThumbnail(page)
563
+ if (BuildConfig.DEBUG) {
564
+ Log.d(TAG, "Thumbnail bitmap created: ${thumbnail.width}x${thumbnail.height}")
565
+ }
566
+ val uri = saveThumbnailToCache(thumbnail, page)
567
+ if (BuildConfig.DEBUG) {
568
+ Log.d(TAG, "Thumbnail saved to: $uri")
569
+ }
570
+
571
+ thumbnailCache[page] = uri
572
+
573
+ withContext(Dispatchers.Main) {
574
+ if (BuildConfig.DEBUG) {
575
+ Log.d(TAG, "Emitting thumbnail generated event for page $page")
576
+ }
577
+ emitThumbnailGenerated(page, uri)
578
+ }
579
+ } catch (e: Exception) {
580
+ Log.e(TAG, "Error generating thumbnail for page $page", e)
581
+ withContext(Dispatchers.Main) {
582
+ emitError("Thumbnail generation failed: ${e.message}", "THUMBNAIL_ERROR")
583
+ }
584
+ } finally {
585
+ pendingThumbnails.remove(page)
586
+ }
587
+ }
588
+ }
589
+
590
+ fun generateAllThumbnails() {
591
+ val renderer = pdfRenderer ?: return
592
+
593
+ componentScope.launch(Dispatchers.IO) {
594
+ for (i in 0 until renderer.pageCount) {
595
+ // Check cache first
596
+ val cachedUri = thumbnailCache[i]
597
+ if (cachedUri != null) {
598
+ withContext(Dispatchers.Main) {
599
+ emitThumbnailGenerated(i, cachedUri)
600
+ }
601
+ continue
602
+ }
603
+
604
+ // Skip if already being generated
605
+ if (!pendingThumbnails.add(i)) {
606
+ continue
607
+ }
608
+
609
+ try {
610
+ val thumbnail = renderThumbnail(i)
611
+ val uri = saveThumbnailToCache(thumbnail, i)
612
+
613
+ thumbnailCache[i] = uri
614
+
615
+ withContext(Dispatchers.Main) {
616
+ emitThumbnailGenerated(i, uri)
617
+ }
618
+
619
+ // Small delay to avoid overwhelming the system
620
+ kotlinx.coroutines.delay(50)
621
+ } catch (e: Exception) {
622
+ Log.e(TAG, "Error generating thumbnail for page $i", e)
623
+ withContext(Dispatchers.Main) {
624
+ emitError("Thumbnail $i failed: ${e.message}", "THUMBNAIL_ERROR")
625
+ }
626
+ } finally {
627
+ pendingThumbnails.remove(i)
628
+ }
629
+ }
630
+ }
631
+ }
632
+
633
+ private fun schedulePreload() {
634
+ // Use Choreographer to schedule on next frame
635
+ Choreographer.getInstance().postFrameCallback {
636
+ preloadVisibleAndAdjacentPages()
637
+ }
638
+ }
639
+
640
+ private fun preloadVisibleAndAdjacentPages() {
641
+ val layoutManager = recyclerView.layoutManager as? LinearLayoutManager ?: return
642
+ val firstVisible = layoutManager.findFirstVisibleItemPosition()
643
+ val lastVisible = layoutManager.findLastVisibleItemPosition()
644
+
645
+ if (firstVisible < 0 || lastVisible < 0) return
646
+
647
+ val pageCount = pdfRenderer?.pageCount ?: return
648
+ val startPage = (firstVisible - PRELOAD_RANGE).coerceAtLeast(0)
649
+ val endPage = (lastVisible + PRELOAD_RANGE).coerceAtMost(pageCount - 1)
650
+
651
+ for (i in startPage..endPage) {
652
+ val cacheKey = "$i-${currentScale.toString().take(4)}"
653
+ if (bitmapCache.get(cacheKey) == null) {
654
+ startPageRender(i)
655
+ }
656
+ }
657
+ }
658
+
659
+ private fun startPageRender(pageIndex: Int) {
660
+ synchronized(activeRenderJobs) {
661
+ if (activeRenderJobs.containsKey(pageIndex)) return
662
+
663
+ val job = componentScope.launch(Dispatchers.IO) {
664
+ try {
665
+ val bitmap = renderPage(pageIndex)
666
+ if (bitmap != null) {
667
+ val cacheKey = "$pageIndex-${currentScale.toString().take(4)}"
668
+ bitmapCache.put(cacheKey, bitmap)
669
+
670
+ withContext(Dispatchers.Main) {
671
+ adapter?.notifyItemChanged(pageIndex)
672
+ }
673
+ }
674
+ } catch (e: Exception) {
675
+ Log.e(TAG, "Error rendering page $pageIndex", e)
676
+ } finally {
677
+ synchronized(activeRenderJobs) {
678
+ activeRenderJobs.remove(pageIndex)
679
+ }
680
+ }
681
+ }
682
+
683
+ activeRenderJobs[pageIndex] = job
684
+ }
685
+ }
686
+
687
+ private suspend fun renderPage(pageIndex: Int): Bitmap? = withContext(Dispatchers.IO) {
688
+ val renderer = pdfRenderer ?: return@withContext null
689
+ if (viewWidth <= 0) return@withContext null
690
+
691
+ try {
692
+ renderMutex.withLock {
693
+ renderer.openPage(pageIndex).use { page ->
694
+ // Calculate base scale to fit page to view width
695
+ val baseScale = viewWidth.toFloat() / page.width
696
+
697
+ // Dynamic quality scaling: reduce quality at higher zoom levels
698
+ // This prevents exponential bitmap growth and OOM errors
699
+ val renderQuality = when {
700
+ currentScale <= 1.0f -> BASE_RENDER_QUALITY // Normal zoom: 1.5x quality
701
+ currentScale <= HIGH_ZOOM_THRESHOLD -> 1.2f // Slight zoom: 1.2x quality
702
+ currentScale <= 2.0f -> 1.0f // Medium zoom: 1.0x quality
703
+ currentScale <= 3.0f -> 0.85f // High zoom: 0.85x quality
704
+ else -> kotlin.math.max(0.7f, 2.5f / currentScale) // Very high zoom: inverse scaling
705
+ }
706
+
707
+ // Calculate final scale with dynamic quality
708
+ val totalScale = baseScale * currentScale * renderQuality
709
+ var bitmapWidth = (page.width * totalScale).toInt().coerceAtLeast(1)
710
+ var bitmapHeight = (page.height * totalScale).toInt().coerceAtLeast(1)
711
+
712
+ // Enforce maximum bitmap dimensions to prevent GPU texture limits
713
+ // Most Android devices support 4096x4096, some up to 8192x8192
714
+ if (bitmapWidth > MAX_BITMAP_DIMENSION || bitmapHeight > MAX_BITMAP_DIMENSION) {
715
+ val aspectRatio = page.width.toFloat() / page.height.toFloat()
716
+ if (bitmapWidth > bitmapHeight) {
717
+ bitmapWidth = MAX_BITMAP_DIMENSION
718
+ bitmapHeight = (MAX_BITMAP_DIMENSION / aspectRatio).toInt()
719
+ } else {
720
+ bitmapHeight = MAX_BITMAP_DIMENSION
721
+ bitmapWidth = (MAX_BITMAP_DIMENSION * aspectRatio).toInt()
722
+ }
723
+ if (BuildConfig.DEBUG) {
724
+ Log.w(TAG, "Page $pageIndex bitmap capped to ${bitmapWidth}x${bitmapHeight} (scale: $currentScale, quality: $renderQuality)")
725
+ }
726
+ }
727
+
728
+ // Calculate approximate memory usage
729
+ val estimatedMemoryMB = (bitmapWidth * bitmapHeight * 4) / (1024 * 1024)
730
+ if (estimatedMemoryMB > 50) {
731
+ if (BuildConfig.DEBUG) {
732
+ Log.w(TAG, "Page $pageIndex: Large bitmap ${bitmapWidth}x${bitmapHeight} (~${estimatedMemoryMB}MB)")
733
+ }
734
+ }
735
+
736
+ if (BuildConfig.DEBUG) {
737
+ Log.d(TAG, "Rendering page $pageIndex: ${bitmapWidth}x${bitmapHeight} (scale: $currentScale, quality: $renderQuality, totalScale: $totalScale)")
738
+ }
739
+
740
+ // ARGB_8888 is required for PdfRenderer
741
+ val bitmap = Bitmap.createBitmap(bitmapWidth, bitmapHeight, Bitmap.Config.ARGB_8888)
742
+
743
+ Canvas(bitmap).drawColor(Color.WHITE)
744
+ page.render(bitmap, null, null, PdfRenderer.Page.RENDER_MODE_FOR_DISPLAY)
745
+
746
+ bitmap
747
+ }
748
+ }
749
+ } catch (e: OutOfMemoryError) {
750
+ Log.e(TAG, "OOM rendering page $pageIndex at scale $currentScale", e)
751
+ // Clear cache and try to recover memory
752
+ bitmapCache.evictAll()
753
+ System.gc()
754
+
755
+ // Emit error to inform user
756
+ withContext(Dispatchers.Main) {
757
+ emitError("Out of memory at zoom level ${currentScale}x. Try reducing zoom.", "OOM_ERROR")
758
+ }
759
+ null
760
+ } catch (e: IllegalArgumentException) {
761
+ Log.e(TAG, "Invalid bitmap dimensions for page $pageIndex", e)
762
+ null
763
+ } catch (e: Exception) {
764
+ Log.e(TAG, "Error rendering page $pageIndex", e)
765
+ null
766
+ }
767
+ }
768
+
769
+ private suspend fun renderThumbnail(pageIndex: Int): Bitmap = withContext(Dispatchers.IO) {
770
+ val renderer = pdfRenderer ?: throw IllegalStateException("No renderer")
771
+
772
+ renderMutex.withLock {
773
+ renderer.openPage(pageIndex).use { page ->
774
+ val thumbSize = 120
775
+ val aspectRatio = page.height.toFloat() / page.width.toFloat()
776
+ val thumbWidth = thumbSize
777
+ val thumbHeight = (thumbSize * aspectRatio).roundToInt()
778
+
779
+ val bitmap = Bitmap.createBitmap(thumbWidth, thumbHeight, Bitmap.Config.ARGB_8888)
780
+ Canvas(bitmap).drawColor(Color.WHITE)
781
+ page.render(bitmap, null, null, PdfRenderer.Page.RENDER_MODE_FOR_DISPLAY)
782
+
783
+ bitmap
784
+ }
785
+ }
786
+ }
787
+
788
+ private fun saveThumbnailToCache(bitmap: Bitmap, page: Int): String {
789
+ val cacheDir = File(context.cacheDir, "PDFThumbnails").apply {
790
+ if (!exists()) mkdirs()
791
+ }
792
+
793
+ val hash = getDocumentHash()
794
+ val file = File(cacheDir, "thumb_${page}_$hash.jpg")
795
+
796
+ FileOutputStream(file).use { out ->
797
+ bitmap.compress(Bitmap.CompressFormat.JPEG, 85, out)
798
+ }
799
+
800
+ if (!bitmap.isRecycled) bitmap.recycle()
801
+
802
+ return "file://${file.absolutePath}"
803
+ }
804
+
805
+ private fun getDocumentHash(): String {
806
+ val uri = sourceUri ?: return "unknown"
807
+ return try {
808
+ MessageDigest.getInstance("MD5")
809
+ .digest(uri.toByteArray())
810
+ .joinToString("") { "%02x".format(it) }
811
+ .substring(0, 8)
812
+ } catch (e: Exception) {
813
+ "unknown"
814
+ }
815
+ }
816
+
817
+ private fun cleanupThumbnailDirectory() {
818
+ try {
819
+ val cacheDir = File(context.cacheDir, "PDFThumbnails")
820
+ if (cacheDir.exists() && cacheDir.isDirectory) {
821
+ cacheDir.listFiles()?.forEach { file ->
822
+ try {
823
+ file.delete()
824
+ } catch (e: Exception) {
825
+ Log.e(TAG, "Error deleting thumbnail file: ${file.name}", e)
826
+ }
827
+ }
828
+ }
829
+ } catch (e: Exception) {
830
+ Log.e(TAG, "Error cleaning up thumbnail directory", e)
831
+ }
832
+ }
833
+
834
+ inner class PdfPageAdapter : RecyclerView.Adapter<PdfPageViewHolder>() {
835
+ override fun getItemCount(): Int = pdfRenderer?.pageCount ?: 0
836
+
837
+ override fun onCreateViewHolder(parent: android.view.ViewGroup, viewType: Int): PdfPageViewHolder {
838
+ val imageView = ImageView(context).apply {
839
+ layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT)
840
+ scaleType = ImageView.ScaleType.FIT_CENTER
841
+ setBackgroundColor(Color.WHITE)
842
+ }
843
+ return PdfPageViewHolder(imageView)
844
+ }
845
+
846
+ override fun onBindViewHolder(holder: PdfPageViewHolder, position: Int) {
847
+ // Set height first
848
+ val dimensions = pageDimensions[position]
849
+ if (dimensions != null && viewWidth > 0) {
850
+ val scale = (viewWidth.toFloat() / dimensions.first) * currentScale
851
+ val targetHeight = (dimensions.second * scale).toInt().coerceAtLeast(100)
852
+ holder.imageView.layoutParams.height = targetHeight
853
+ }
854
+
855
+ // Check cache for valid bitmap at current scale
856
+ val cacheKey = "$position-${currentScale.toString().take(4)}"
857
+ val cached = bitmapCache.get(cacheKey)
858
+ if (cached != null && !cached.isRecycled) {
859
+ holder.imageView.setImageBitmap(cached)
860
+ } else {
861
+ // Show placeholder and trigger render
862
+ holder.imageView.setImageBitmap(createPlaceholderBitmap(position))
863
+ startPageRender(position)
864
+ }
865
+ }
866
+
867
+ override fun onViewRecycled(holder: PdfPageViewHolder) {
868
+ super.onViewRecycled(holder)
869
+ // Clear reference but don't recycle the bitmap (cache may still have it)
870
+ holder.imageView.setImageBitmap(null)
871
+ }
872
+
873
+ private fun createPlaceholderBitmap(pageIndex: Int): Bitmap {
874
+ val dimensions = pageDimensions[pageIndex]
875
+ val w = dimensions?.let { (it.first * 0.1f).toInt() } ?: 100
876
+ val h = dimensions?.let { (it.second * 0.1f).toInt() } ?: 100
877
+
878
+ return Bitmap.createBitmap(w.coerceAtLeast(10), h.coerceAtLeast(10), Bitmap.Config.ARGB_8888).apply {
879
+ Canvas(this).drawColor(Color.WHITE)
880
+ }
881
+ }
882
+ }
883
+
884
+ inner class PdfPageViewHolder(val imageView: ImageView) : RecyclerView.ViewHolder(imageView)
885
+
886
+ inner class ScaleListener : ScaleGestureDetector.SimpleOnScaleGestureListener() {
887
+ override fun onScale(detector: ScaleGestureDetector): Boolean {
888
+ if (!enableZoom) return false
889
+
890
+ val newScale = (currentScale * detector.scaleFactor).coerceIn(minScale, maxScale)
891
+ if (newScale != currentScale) {
892
+ setScale(newScale)
893
+ }
894
+ return true
895
+ }
896
+
897
+ override fun onScaleBegin(detector: ScaleGestureDetector): Boolean = enableZoom
898
+ }
899
+
900
+ inner class GestureListener : GestureDetector.SimpleOnGestureListener() {
901
+ override fun onDoubleTap(e: MotionEvent): Boolean {
902
+ if (!enableZoom) return false
903
+
904
+ // Toggle between current scale and 2x zoom (or reset to 1.0 if already zoomed)
905
+ val targetScale = if (currentScale > 1.5f) 1.0f else 2.0f
906
+ setScale(targetScale)
907
+ return true
908
+ }
909
+ }
910
+
911
+ // Event emitters
912
+ private fun emitLoadComplete(pageCount: Int, pageWidth: Int, pageHeight: Int) {
913
+ // Call Nitro callback
914
+ onLoadCompleteCallback?.invoke(pageCount, pageWidth, pageHeight)
915
+ }
916
+
917
+ private fun emitPageChange(page: Int) {
918
+ val pageCount = pdfRenderer?.pageCount ?: return
919
+
920
+ // Call Nitro callback
921
+ onPageChangeCallback?.invoke(page, pageCount)
922
+ }
923
+
924
+ private fun emitScaleChange(scale: Float) {
925
+ // Call Nitro callback
926
+ onScaleChangeCallback?.invoke(scale)
927
+ }
928
+
929
+ private fun emitError(message: String, code: String) {
930
+ // Call Nitro callback
931
+ onErrorCallback?.invoke(message, code)
932
+ }
933
+
934
+ private fun emitThumbnailGenerated(page: Int, uri: String) {
935
+ // Call Nitro callback
936
+ onThumbnailGeneratedCallback?.invoke(page, uri)
937
+ }
938
+
939
+ private fun emitLoadingChange(loading: Boolean) {
940
+ // Call Nitro callback
941
+ onLoadingChangeCallback?.invoke(loading)
942
+ }
943
+
944
+ override fun onDetachedFromWindow() {
945
+ super.onDetachedFromWindow()
946
+
947
+ // Cancel all coroutines
948
+ componentScope.cancel()
949
+ cancelAllRenderJobs()
950
+
951
+ // Wait for RecyclerView to finish drawing before recycling bitmaps
952
+ post {
953
+ val snapshot = bitmapCache.snapshot()
954
+ bitmapCache.evictAll()
955
+
956
+ // Now safe to recycle - views are detached
957
+ snapshot.values.forEach { bitmap ->
958
+ if (!bitmap.isRecycled) {
959
+ try {
960
+ bitmap.recycle()
961
+ } catch (e: Exception) {
962
+ Log.e(TAG, "Error recycling bitmap", e)
963
+ }
964
+ }
965
+ }
966
+ }
967
+
968
+ closePdfRenderer()
969
+ pageDimensions.clear()
970
+ thumbnailCache.clear()
971
+ pendingThumbnails.clear()
972
+
973
+ // Clean up thumbnail directory
974
+ cleanupThumbnailDirectory()
975
+ }
976
+
977
+ // Extension function for try-with-resources pattern
978
+ private inline fun <T : AutoCloseable, R> T.use(block: (T) -> R): R {
979
+ var exception: Throwable? = null
980
+ try {
981
+ return block(this)
982
+ } catch (e: Throwable) {
983
+ exception = e
984
+ throw e
985
+ } finally {
986
+ when {
987
+ exception == null -> close()
988
+ else -> try {
989
+ close()
990
+ } catch (closeException: Throwable) {
991
+ exception.addSuppressed(closeException)
992
+ }
993
+ }
994
+ }
995
+ }
996
+ }