@thatkid02/react-native-pdf-viewer 0.0.3 → 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.
@@ -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.graphics.Rect
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
- // Threshold for using reduced quality
50
- private const val HIGH_ZOOM_THRESHOLD = 1.5f
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 components
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
- // UI components
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
- // Gesture handling
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 = 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
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 { loadDocument(it) }
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
- field = value
124
- updateContentInsets()
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
- // Runtime state
136
- private var currentScale = 1.0f
137
- private var lastReportedPage = -1
138
- private var isLoading = false
139
- private var viewWidth = 0
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
- recyclerView = RecyclerView(context).apply {
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 applySpacing() {
208
- // Add spacing between PDF pages
209
- recyclerView.addItemDecoration(object : RecyclerView.ItemDecoration() {
210
- override fun getItemOffsets(outRect: Rect, view: View, parent: RecyclerView, state: RecyclerView.State) {
211
- val position = parent.getChildAdapterPosition(view)
212
- if (position != RecyclerView.NO_POSITION && position > 0) {
213
- outRect.top = spacing.toInt()
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
- // Android always uses vertical scroll (horizontal is iOS-only)
232
- val layoutManager = LinearLayoutManager(context, LinearLayoutManager.VERTICAL, false)
233
- recyclerView.layoutManager = layoutManager
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.addOnScrollListener(object : RecyclerView.OnScrollListener() {
236
- private var scrollState = RecyclerView.SCROLL_STATE_IDLE
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
- override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
239
- super.onScrolled(recyclerView, dx, dy)
240
-
241
- val layoutManager = recyclerView.layoutManager as? LinearLayoutManager ?: return
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
- override fun onScrollStateChanged(recyclerView: RecyclerView, newState: Int) {
255
- super.onScrollStateChanged(recyclerView, newState)
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
- var handled = false
365
+ if (!enableZoom) {
366
+ return recyclerView.onTouchEvent(event)
367
+ }
267
368
 
268
- if (enableZoom) {
269
- // Try double-tap first (gestureDetector)
270
- gestureDetector.onTouchEvent(event)
271
-
272
- // Then try scale gesture detector
273
- scaleGestureDetector.onTouchEvent(event)
274
- handled = scaleGestureDetector.isInProgress
275
- isScaling = scaleGestureDetector.isInProgress
276
-
277
- if (isScaling) {
278
- // During scaling, request not to be intercepted
279
- parent?.requestDisallowInterceptTouchEvent(true)
280
- recyclerView.requestDisallowInterceptTouchEvent(true)
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
- parent?.requestDisallowInterceptTouchEvent(false)
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 handled || super.onTouchEvent(event)
434
+ return recyclerView.onTouchEvent(event)
296
435
  }
297
436
 
298
437
  override fun onInterceptTouchEvent(event: MotionEvent): Boolean {
299
- // Check for pinch gesture
300
- if (enableZoom) {
301
- scaleGestureDetector.onTouchEvent(event)
302
- if (scaleGestureDetector.isInProgress) {
303
- return true
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
- Log.d(TAG, "loadDocument called with uri: $uri")
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
- // Cancel previous load if in progress
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
- withContext(Dispatchers.Main) {
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
- // Preload first 5-10 pages to improve initial scrolling
436
- val pagesToPreload = minOf(10, renderer.pageCount)
437
- for (i in 1 until pagesToPreload) { // Start from 1 since 0 is already loaded
438
- try {
439
- kotlinx.coroutines.delay(50) // Small delay to not block other operations
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 lower priority
453
- if (renderer.pageCount > pagesToPreload) {
454
- for (i in pagesToPreload until renderer.pageCount) {
455
- try {
456
- kotlinx.coroutines.delay(100) // Longer delay for non-critical pages
457
- renderMutex.withLock {
458
- if (pageDimensions[i] == null) {
459
- renderer.openPage(i).use { page ->
460
- pageDimensions[i] = Pair(page.width, page.height)
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
- // Get page dimensions on-demand if not already cached
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
- // Load on-demand
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
- Pair(page.width, page.height).also {
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
- Pair(612, 792) // Fallback
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
- try {
498
- pdfRenderer?.close()
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
- parcelFileDescriptor?.close()
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
- Log.e(TAG, "Error closing file descriptor", e)
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
- private suspend fun downloadOrGetFile(uri: String): File = withContext(Dispatchers.IO) {
536
- when {
537
- uri.startsWith("file://") -> File(uri.substring(7))
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 ?: run {
624
- emitError("PDF not loaded", "NOT_LOADED")
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
- Log.d(TAG, "goToPage called: $page")
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
- if (!enableZoom) {
649
- emitError("Zoom is disabled", "ZOOM_DISABLED")
650
- return
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).let { it < 0.01f && it > -0.01f }) return
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
- // Update adapter to refresh all views
665
- adapter?.notifyDataSetChanged()
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
- // Force immediate render of visible pages
669
- post {
670
- val layoutManager = recyclerView.layoutManager as? LinearLayoutManager
671
- val firstVisible = layoutManager?.findFirstVisibleItemPosition() ?: 0
672
- val lastVisible = layoutManager?.findLastVisibleItemPosition() ?: 0
673
-
674
- for (i in firstVisible..lastVisible) {
675
- startPageRender(i)
676
- }
677
-
678
- // Preload adjacent pages after a delay
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
- Log.d(TAG, "generateThumbnail called for page: $page")
686
- }
687
- val renderer = pdfRenderer ?: run {
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
- // Check cache first
697
- thumbnailCache[page]?.let { cachedUri ->
698
- if (BuildConfig.DEBUG) {
699
- Log.d(TAG, "Thumbnail for page $page found in cache: $cachedUri")
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 if already being generated (thread-safe)
706
- if (!pendingThumbnails.add(page)) {
707
- if (BuildConfig.DEBUG) {
708
- Log.d(TAG, "Thumbnail for page $page already being generated")
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
- if (BuildConfig.DEBUG) {
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
- // Check cache first
752
- val cachedUri = thumbnailCache[i]
753
- if (cachedUri != null) {
754
- withContext(Dispatchers.Main) {
755
- emitThumbnailGenerated(i, cachedUri)
756
- }
757
- continue
758
- }
759
-
760
- // Skip if already being generated
761
- if (!pendingThumbnails.add(i)) {
762
- continue
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
- try {
766
- val thumbnail = renderThumbnail(i)
767
- val uri = saveThumbnailToCache(thumbnail, i)
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
- val cacheKey = "$i-${currentScale.toString().take(4)}"
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
- val bitmap = renderPage(pageIndex)
822
- if (bitmap != null) {
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
- // Calculate base scale to fit page to view width
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
- // Calculate final scale with dynamic quality
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
- // Enforce maximum bitmap dimensions to prevent GPU texture limits
869
- // Most Android devices support 4096x4096, some up to 8192x8192
870
- if (bitmapWidth > MAX_BITMAP_DIMENSION || bitmapHeight > MAX_BITMAP_DIMENSION) {
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
- Log.e(TAG, "OOM rendering page $pageIndex at scale $currentScale", e)
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 suspend fun renderThumbnail(pageIndex: Int): Bitmap = withContext(Dispatchers.IO) {
926
- val renderer = pdfRenderer ?: throw IllegalStateException("No renderer")
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
- renderMutex.withLock {
929
- renderer.openPage(pageIndex).use { page ->
930
- val thumbSize = 120
931
- val aspectRatio = page.height.toFloat() / page.width.toFloat()
932
- val thumbWidth = thumbSize
933
- val thumbHeight = (thumbSize * aspectRatio).roundToInt()
934
-
935
- val bitmap = Bitmap.createBitmap(thumbWidth, thumbHeight, Bitmap.Config.ARGB_8888)
936
- Canvas(bitmap).drawColor(Color.WHITE)
937
- page.render(bitmap, null, null, PdfRenderer.Page.RENDER_MODE_FOR_DISPLAY)
938
-
939
- bitmap
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 saveThumbnailToCache(bitmap: Bitmap, page: Int): String {
945
- val cacheDir = File(context.cacheDir, "PDFThumbnails").apply {
946
- if (!exists()) mkdirs()
947
- }
1097
+ private fun logBitmapStats(pageIndex: Int, width: Int, height: Int) {
1098
+ if (!BuildConfig.DEBUG) return
948
1099
 
949
- val hash = getDocumentHash()
950
- val file = File(cacheDir, "thumb_${page}_$hash.jpg")
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 getDocumentHash(): String {
962
- val uri = sourceUri ?: return "unknown"
963
- return try {
964
- MessageDigest.getInstance("MD5")
965
- .digest(uri.toByteArray())
966
- .joinToString("") { "%02x".format(it) }
967
- .substring(0, 8)
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
- private fun cleanupThumbnailDirectory() {
974
- try {
975
- val cacheDir = File(context.cacheDir, "PDFThumbnails")
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
- // Set height first
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
- val scale = (viewWidth.toFloat() / dimensions.first) * currentScale
1007
- val targetHeight = (dimensions.second * scale).toInt().coerceAtLeast(100)
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
- // Trigger lazy loading of dimensions in background
1014
- componentScope.launch(Dispatchers.IO) {
1015
- val dims = getPageDimensions(position)
1016
- withContext(Dispatchers.Main) {
1017
- // Update the view if it's still showing this position
1018
- if (holder.bindingAdapterPosition == position) {
1019
- val scale = (viewWidth.toFloat() / dims.first) * currentScale
1020
- val targetHeight = (dims.second * scale).toInt().coerceAtLeast(100)
1021
- holder.imageView.layoutParams.height = targetHeight
1022
- holder.imageView.requestLayout()
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
- // Check cache for valid bitmap at current scale
1029
- val cacheKey = "$position-${currentScale.toString().take(4)}"
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.first * 0.1f).toInt() } ?: 100
1049
- val h = dimensions?.let { (it.second * 0.1f).toInt() } ?: 100
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(w.coerceAtLeast(10), h.coerceAtLeast(10), Bitmap.Config.ARGB_8888).apply {
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
- inner class ScaleListener : ScaleGestureDetector.SimpleOnScaleGestureListener() {
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 != currentScale) {
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 onScaleBegin(detector: ScaleGestureDetector): Boolean = enableZoom
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
- // Toggle between current scale and 2x zoom (or reset to 1.0 if already zoomed)
1078
- val targetScale = if (currentScale > 1.5f) 1.0f else 2.0f
1079
- setScale(targetScale)
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
- // Event emitters
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
- // Cancel all coroutines
1287
+ ProcessLifecycleOwner.get().lifecycle.removeObserver(lifecycleObserver)
1121
1288
  componentScope.cancel()
1122
1289
  cancelAllRenderJobs()
1123
1290
 
1124
- // Wait for RecyclerView to finish drawing before recycling bitmaps
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
- // Extension function for try-with-resources pattern
1151
- private inline fun <T : AutoCloseable, R> T.use(block: (T) -> R): R {
1152
- var exception: Throwable? = null
1153
- try {
1154
- return block(this)
1155
- } catch (e: Throwable) {
1156
- exception = e
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
  }