react-native-pdfrender 0.1.0

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 (68) hide show
  1. package/README.md +306 -0
  2. package/android/build.gradle +76 -0
  3. package/android/src/main/AndroidManifest.xml +2 -0
  4. package/android/src/main/java/com/pdfrender/ComposeRenderer.kt +85 -0
  5. package/android/src/main/java/com/pdfrender/PdfCacheManager.kt +150 -0
  6. package/android/src/main/java/com/pdfrender/PdfConstants.kt +63 -0
  7. package/android/src/main/java/com/pdfrender/PdfIconComponents.kt +275 -0
  8. package/android/src/main/java/com/pdfrender/PdfRenderingLogic.kt +325 -0
  9. package/android/src/main/java/com/pdfrender/PdfUIComponents.kt +335 -0
  10. package/android/src/main/java/com/pdfrender/PdfViewPackage.kt +32 -0
  11. package/android/src/main/java/com/pdfrender/PdfViewerActivity.kt +3467 -0
  12. package/android/src/main/java/com/pdfrender/PdfViewerFabricManager.kt +244 -0
  13. package/android/src/main/java/com/pdfrender/PdfViewerFragment.kt +129 -0
  14. package/android/src/main/java/com/pdfrender/PdfViewerTurboModule.kt +158 -0
  15. package/android/src/main/java/com/pdfrender/events/FullScreenChangeEvent.kt +26 -0
  16. package/android/src/main/java/com/pdfrender/events/LeftScreenChangeEvent.kt +22 -0
  17. package/android/src/main/java/com/pdfrender/events/RightScreenChangeEvent.kt +22 -0
  18. package/android/src/main/java/com/pdfrender/events/ZoomChangeEvent.kt +22 -0
  19. package/ios/PdfCacheManager.swift +44 -0
  20. package/ios/PdfConstants.swift +38 -0
  21. package/ios/PdfPageView.swift +121 -0
  22. package/ios/PdfRenderingLogic.swift +107 -0
  23. package/ios/PdfToolbarView.swift +158 -0
  24. package/ios/PdfViewerComponentView.mm +194 -0
  25. package/ios/PdfViewerTurboModule.mm +186 -0
  26. package/ios/PdfViewerTurboModuleImpl.swift +141 -0
  27. package/ios/PdfViewerView.swift +268 -0
  28. package/ios/PdfViewerViewController.swift +109 -0
  29. package/lib/commonjs/PdfViewerView.js +105 -0
  30. package/lib/commonjs/PdfViewerView.js.map +1 -0
  31. package/lib/commonjs/index.js +28 -0
  32. package/lib/commonjs/index.js.map +1 -0
  33. package/lib/commonjs/package.json +1 -0
  34. package/lib/commonjs/specs/NativePdfViewerComponent.js +27 -0
  35. package/lib/commonjs/specs/NativePdfViewerComponent.js.map +1 -0
  36. package/lib/commonjs/specs/NativePdfViewerModule.js +21 -0
  37. package/lib/commonjs/specs/NativePdfViewerModule.js.map +1 -0
  38. package/lib/commonjs/usePdfViewer.js +65 -0
  39. package/lib/commonjs/usePdfViewer.js.map +1 -0
  40. package/lib/module/PdfViewerView.js +99 -0
  41. package/lib/module/PdfViewerView.js.map +1 -0
  42. package/lib/module/index.js +8 -0
  43. package/lib/module/index.js.map +1 -0
  44. package/lib/module/package.json +1 -0
  45. package/lib/module/specs/NativePdfViewerComponent.js +26 -0
  46. package/lib/module/specs/NativePdfViewerComponent.js.map +1 -0
  47. package/lib/module/specs/NativePdfViewerModule.js +18 -0
  48. package/lib/module/specs/NativePdfViewerModule.js.map +1 -0
  49. package/lib/module/usePdfViewer.js +60 -0
  50. package/lib/module/usePdfViewer.js.map +1 -0
  51. package/lib/typescript/PdfViewerView.d.ts +58 -0
  52. package/lib/typescript/PdfViewerView.d.ts.map +1 -0
  53. package/lib/typescript/index.d.ts +7 -0
  54. package/lib/typescript/index.d.ts.map +1 -0
  55. package/lib/typescript/specs/NativePdfViewerComponent.d.ts +59 -0
  56. package/lib/typescript/specs/NativePdfViewerComponent.d.ts.map +1 -0
  57. package/lib/typescript/specs/NativePdfViewerModule.d.ts +47 -0
  58. package/lib/typescript/specs/NativePdfViewerModule.d.ts.map +1 -0
  59. package/lib/typescript/usePdfViewer.d.ts +45 -0
  60. package/lib/typescript/usePdfViewer.d.ts.map +1 -0
  61. package/package.json +109 -0
  62. package/react-native-pdfrender.podspec +35 -0
  63. package/react-native.config.js +11 -0
  64. package/src/PdfViewerView.tsx +159 -0
  65. package/src/index.tsx +10 -0
  66. package/src/specs/NativePdfViewerComponent.ts +94 -0
  67. package/src/specs/NativePdfViewerModule.ts +58 -0
  68. package/src/usePdfViewer.ts +102 -0
@@ -0,0 +1,3467 @@
1
+ package com.pdfrender
2
+
3
+ /**
4
+ * PDF Viewer Activity - Main Entry Point
5
+ *
6
+ * This file contains the main PDF viewer implementation with the following structure:
7
+ *
8
+ * FILE ORGANIZATION:
9
+ * ==================
10
+ * 1. PdfViewerActivity - Android Activity class (entry point)
11
+ * 2. PdfRendererView - Main composable that orchestrates the PDF viewer
12
+ * 3. State Management - All state variables and their initialization
13
+ * 4. Helper Functions - Utility functions for safe context switching
14
+ * 5. Rendering System - Render queue, worker, and page rendering logic
15
+ * 6. Cache Management - Batch loading and cache size management
16
+ * 7. Gesture Handling - Pinch zoom gesture detection and handling
17
+ * 8. Zoom Management - Zoom change handling and debouncing
18
+ * 9. UI Components - Scaffold, LazyColumn, and page display
19
+ *
20
+ * RELATED FILES:
21
+ * ==============
22
+ * - PdfConstants.kt - Constants and configuration values
23
+ * - PdfRenderingLogic.kt - Page rendering functions
24
+ * - PdfCacheManager.kt - Cache management utilities
25
+ * - PdfUIComponents.kt - Reusable UI components
26
+ *
27
+ * KEY CONCEPTS:
28
+ * ============
29
+ * - Batch Loading: Pages are loaded in batches of 15 for memory efficiency
30
+ * - Render Queue: Sequential processing of render requests prevents conflicts
31
+ * - Cache Management: Keeps only nearby batches in memory to prevent OOM
32
+ * - Gesture Handling: Smooth pinch zoom with 60fps updates
33
+ * - Scale Management: Tracks rendered vs. current scale to avoid stale renders
34
+ */
35
+
36
+ import android.graphics.Bitmap
37
+ import android.graphics.Canvas
38
+ import android.graphics.Color as AndroidColor
39
+ import android.graphics.pdf.PdfRenderer
40
+ import android.net.Uri
41
+ import android.os.Bundle
42
+ import android.os.ParcelFileDescriptor
43
+ import android.util.Log
44
+ import java.io.InputStream
45
+ import androidx.activity.ComponentActivity
46
+ import androidx.activity.compose.setContent
47
+ import androidx.compose.foundation.Image
48
+ import androidx.compose.ui.res.painterResource
49
+ import androidx.compose.ui.platform.LocalContext
50
+ import android.graphics.BitmapFactory
51
+ import kotlinx.coroutines.Dispatchers
52
+ import kotlinx.coroutines.withContext
53
+ import java.net.URL
54
+ import androidx.compose.foundation.layout.*
55
+ import androidx.compose.foundation.lazy.LazyColumn
56
+ import androidx.compose.foundation.lazy.itemsIndexed
57
+ import androidx.compose.foundation.lazy.rememberLazyListState
58
+ import androidx.compose.foundation.horizontalScroll
59
+ import androidx.compose.foundation.rememberScrollState
60
+ import androidx.compose.foundation.gestures.detectTransformGestures
61
+ import androidx.compose.foundation.gestures.detectTapGestures
62
+ import androidx.compose.material.Button
63
+ import androidx.compose.material.ButtonDefaults
64
+ import androidx.compose.material.Text
65
+ import androidx.compose.material.MaterialTheme
66
+ import androidx.compose.material.Scaffold
67
+ import androidx.compose.material.TopAppBar
68
+ import androidx.compose.material.CircularProgressIndicator
69
+ import androidx.compose.material.LinearProgressIndicator
70
+ import androidx.compose.material.Slider
71
+ import androidx.compose.material.Card
72
+ import androidx.compose.runtime.*
73
+ import androidx.compose.ui.Alignment
74
+ import androidx.compose.ui.Modifier
75
+ import androidx.compose.ui.graphics.asImageBitmap
76
+ import androidx.compose.ui.graphics.graphicsLayer
77
+ import androidx.compose.ui.input.pointer.pointerInput
78
+ import androidx.compose.ui.input.pointer.PointerEventPass
79
+ import androidx.compose.ui.unit.dp
80
+ import androidx.compose.ui.layout.ContentScale
81
+ import androidx.compose.foundation.shape.RoundedCornerShape
82
+ import androidx.compose.ui.platform.LocalConfiguration
83
+ import androidx.compose.ui.platform.LocalDensity
84
+ import androidx.compose.foundation.layout.size
85
+ import androidx.compose.ui.draw.drawBehind
86
+ import androidx.compose.ui.geometry.Offset
87
+ import androidx.compose.ui.geometry.Size
88
+ import androidx.compose.ui.graphics.Path
89
+ import androidx.compose.ui.graphics.drawscope.Stroke
90
+ import androidx.compose.foundation.Canvas
91
+ import androidx.compose.foundation.border
92
+ import androidx.compose.foundation.clickable
93
+ import androidx.compose.foundation.background
94
+ import androidx.compose.ui.graphics.Color
95
+ import androidx.compose.ui.graphics.ColorFilter
96
+ import androidx.compose.material.icons.Icons
97
+ import androidx.compose.material.icons.filled.ArrowBack
98
+ import androidx.compose.material.Icon
99
+ import androidx.compose.ui.draw.clip
100
+ import androidx.compose.ui.text.font.FontWeight
101
+ import androidx.compose.ui.unit.LayoutDirection
102
+ import androidx.compose.ui.tooling.preview.Preview
103
+ import kotlinx.coroutines.withContext
104
+ import kotlinx.coroutines.launch
105
+ import kotlinx.coroutines.async
106
+ import kotlinx.coroutines.awaitAll
107
+ import kotlinx.coroutines.delay
108
+ import kotlinx.coroutines.sync.Mutex
109
+ import kotlinx.coroutines.sync.Semaphore
110
+ import kotlinx.coroutines.sync.withLock
111
+ import kotlinx.coroutines.sync.withPermit
112
+ import kotlinx.coroutines.channels.Channel
113
+ import kotlinx.coroutines.flow.MutableStateFlow
114
+ import kotlinx.coroutines.flow.StateFlow
115
+ import kotlinx.coroutines.channels.BufferOverflow
116
+ import kotlinx.coroutines.Job
117
+ import androidx.compose.animation.core.AnimationState
118
+ import androidx.compose.animation.core.animateDecay
119
+ import androidx.compose.animation.core.exponentialDecay
120
+ import androidx.compose.runtime.DisposableEffect
121
+ import androidx.compose.ui.input.pointer.util.VelocityTracker
122
+ import java.util.concurrent.atomic.AtomicBoolean
123
+ import java.util.concurrent.ConcurrentHashMap
124
+
125
+ // ============================================================================
126
+ // SECTION 1: ACTIVITY CLASS
127
+ // ============================================================================
128
+
129
+ /**
130
+ * Main Activity for PDF Viewer
131
+ *
132
+ * Receives PDF URI and optional initial page index from intent,
133
+ * then displays the PDF viewer composable.
134
+ */
135
+ class PdfViewerActivity : ComponentActivity() {
136
+
137
+ override fun onCreate(savedInstanceState: Bundle?) {
138
+ super.onCreate(savedInstanceState)
139
+
140
+ val uriString = intent?.getStringExtra("pdf_uri") ?: ""
141
+ val uri = Uri.parse(uriString)
142
+ val initialPageIndex = intent?.getIntExtra("initial_page_index", -1) ?: -1
143
+
144
+ setContent {
145
+ MaterialTheme {
146
+ Box(modifier = Modifier.fillMaxSize().background(Color.Transparent)) {
147
+ PdfRendererView(uri, initialPageIndex)
148
+ }
149
+ }
150
+ }
151
+ }
152
+ }
153
+
154
+ // ============================================================================
155
+ // SECTION 2: MAIN COMPOSABLE - PDF RENDERER VIEW
156
+ // ============================================================================
157
+
158
+ /**
159
+ * Main PDF Renderer View Composable
160
+ *
161
+ * This is the root composable that manages the entire PDF viewer state and UI.
162
+ * It handles:
163
+ * - PDF loading and initialization
164
+ * - Page rendering and caching
165
+ * - Gesture handling (pinch zoom)
166
+ * - Zoom management
167
+ * - Scroll handling
168
+ *
169
+ * @param uri The URI of the PDF file to display (default: Uri.EMPTY for preview)
170
+ * @param initialPageIndex The page index to scroll to initially (-1 for start)
171
+ */
172
+ @Preview(showBackground = true)
173
+ @Composable
174
+ fun PdfRendererView(
175
+ uri: Uri = Uri.EMPTY,
176
+ initialPageIndex: Int = -1,
177
+ maxWidthDp: androidx.compose.ui.unit.Dp? = null,
178
+ maxZoom: Float = PdfViewerConfig.MAX_SCALE_SMALL_SCREEN,
179
+ screenWidthPercentage: Float = 100f,
180
+ iconUrls: Map<String, String?>? = null,
181
+ iconSize: androidx.compose.ui.unit.Dp = 40.dp, // Dynamic icon size with default
182
+ defaultZoom: Float = 1.5f, // Dynamic default zoom scale with fallback
183
+ isFullScreen: Boolean = false,
184
+ rightLayout: Boolean = false, // PDF position: true = right side, false = left side
185
+ backButtonText: String? = null,
186
+ headerText: String? = null,
187
+ onFullScreenChange: ((Boolean) -> Unit)? = null,
188
+ onLeftScreenChange: ((Boolean) -> Unit)? = null,
189
+ onRightScreenChange: ((Boolean) -> Unit)? = null,
190
+ onZoomChange: ((Int) -> Unit)? = null
191
+ ) {
192
+
193
+ // ========================================================================
194
+ // SECTION 2.1: STATE VARIABLES - PDF Data
195
+ // ========================================================================
196
+
197
+ var pageCount by remember { mutableStateOf(0) }
198
+ val context = androidx.compose.ui.platform.LocalContext.current
199
+ var pdfRenderer by remember { mutableStateOf<PdfRenderer?>(null) }
200
+ var pfd by remember { mutableStateOf<ParcelFileDescriptor?>(null) }
201
+
202
+ // ========================================================================
203
+ // SECTION 2.2: STATE VARIABLES - Page Cache and Rendering
204
+ // ========================================================================
205
+
206
+ // Cache map: pageIndex -> (Bitmap, scale at which it was rendered)
207
+ var pageCache by remember { mutableStateOf<Map<Int, Pair<Bitmap, Float>>>(emptyMap()) }
208
+ val renderingPages = remember { ConcurrentHashMap<Int, Long>() } // Thread-safe: pageIndex -> timestamp
209
+ val queuedPages = remember { mutableStateOf<Set<Int>>(emptySet()) } // Pages queued for rendering
210
+ val activeBitmaps = remember { mutableSetOf<Bitmap>() } // Track active bitmaps to prevent recycling
211
+ var reloadVisiblePagesToken by remember { mutableStateOf(0) } // Triggers reload of currently visible pages
212
+ val renderingStats = remember { RenderingStats() } // Track rendering performance statistics
213
+
214
+ // ========================================================================
215
+ // SECTION 2.3: STATE VARIABLES - Zoom and Scale
216
+ // ========================================================================
217
+
218
+ // ========================================================================
219
+ // ZOOM CONSTANTS - STRICT REQUIREMENTS
220
+ // ========================================================================
221
+ // If defaultZoom is provided from JS, use it as the minimum zoom floor.
222
+ // Otherwise fall back to the global constant (PdfViewerConfig.MIN_SCALE).
223
+ val MIN_ZOOM = defaultZoom.coerceAtMost(PdfViewerConfig.MAX_SCALE_SMALL_SCREEN)
224
+
225
+ // Initialize scale to defaultZoom (dynamic from JS)
226
+ var scale by remember { mutableStateOf(defaultZoom) } // Current zoom scale (dynamic default)
227
+ var lastRenderedScale by remember { mutableStateOf(defaultZoom) } // Last scale that was rendered (dynamic default)
228
+ var isZoomInProgress by remember { mutableStateOf(false) } // True when zoom is changing
229
+ var lastScaleUpdateTime by remember { mutableStateOf(0L) } // Throttle scale updates
230
+
231
+ // ========================================================================
232
+ // SECTION 2.4: STATE VARIABLES - Gesture Handling
233
+ // ========================================================================
234
+
235
+ var isPinchZoomActive by remember { mutableStateOf(false) } // Track active pinch gesture
236
+ var lastPinchZoomTime by remember { mutableStateOf(0L) } // Track last pinch zoom time
237
+ var touchGestureCount by remember { mutableStateOf(0) } // Track number of touch gestures
238
+ var recentPinchZoom by remember { mutableStateOf(false) } // Track if pinch zoom happened recently (keeps pages visible)
239
+ // Initialize fullscreen state from prop (React Native controls the state)
240
+ var isFullScreenState by remember { mutableStateOf(true) } // Default to fullscreen
241
+
242
+ // Sync with prop changes from React Native (parent controls fullscreen)
243
+ LaunchedEffect(isFullScreen) {
244
+ isFullScreenState = isFullScreen
245
+ }
246
+
247
+ // ========================================================================
248
+ // SECTION 2.5: STATE VARIABLES - Loading State
249
+ // ========================================================================
250
+
251
+ var isInitialLoading by remember { mutableStateOf(true) }
252
+ var loadingProgress by remember { mutableStateOf(0) } // 0-100 percentage
253
+ var loadingStatus by remember { mutableStateOf("Opening PDF...") }
254
+
255
+ // ========================================================================
256
+ // SECTION 2.6: STATE VARIABLES - Batch Management
257
+ // ========================================================================
258
+
259
+ var loadedBatches by remember { mutableStateOf<Set<Int>>(emptySet()) } // Track which batches are loaded
260
+
261
+ // ========================================================================
262
+ // SECTION 2.7: CONFIGURATION AND HELPERS
263
+ // ========================================================================
264
+
265
+ val renderMutex = remember { Mutex() } // Mutex for thread-safe PDF renderer access
266
+ val renderQueue = remember { Channel<RenderRequest>(capacity = Channel.UNLIMITED) } // Render request queue
267
+ val configuration = LocalConfiguration.current
268
+ val screenWidthDp = maxWidthDp ?: configuration.screenWidthDp.dp // Use maxWidthDp if provided, otherwise use screen width
269
+ val density = androidx.compose.ui.platform.LocalDensity.current.density
270
+
271
+ // Use maxZoom parameter directly (always 2.5f = 250% from ViewManager)
272
+ // This ensures both buttons and pinch gestures use the same limits: 100% min, 250% max
273
+ // The maxZoom parameter is always set to 2.5f (250%) to match button zoom limits
274
+ val effectiveMaxZoom = maxZoom.coerceIn(MIN_ZOOM, PdfViewerConfig.MAX_SCALE_SMALL_SCREEN)
275
+
276
+ // Ensure scale is within STRICT bounds (1.0f MINIMUM to effectiveMaxZoom MAXIMUM)
277
+ LaunchedEffect(effectiveMaxZoom) {
278
+ scale = scale.coerceIn(MIN_ZOOM, effectiveMaxZoom)
279
+ lastRenderedScale = lastRenderedScale.coerceIn(MIN_ZOOM, effectiveMaxZoom)
280
+ }
281
+
282
+ // Send zoom percentage updates to React Native when scale changes
283
+ LaunchedEffect(scale) {
284
+ // Convert scale (1.0 = 100%) to percentage and send to React Native
285
+ val zoomPercentage = (scale * 100).toInt()
286
+ onZoomChange?.invoke(zoomPercentage)
287
+ }
288
+
289
+ // Calculate button disabled states - derived from zoom bounds (NO re-renders)
290
+ val isZoomInDisabled = scale >= effectiveMaxZoom
291
+ val isZoomOutDisabled = scale <= MIN_ZOOM
292
+
293
+ // Calculate layout button disabled states based on PDF position
294
+ // When rightLayout = true: PDF is on right → disable right button, enable left button
295
+ // When rightLayout = false: PDF is on left → disable left button, enable right button
296
+ val isLeftLayoutDisabled = !rightLayout // Disable left button when PDF is on left
297
+ val isRightLayoutDisabled = rightLayout // Disable right button when PDF is on right
298
+
299
+ // Use constants from PdfViewerConfig
300
+ val batchSize = PdfViewerConfig.BATCH_SIZE
301
+ val maxCacheSize = PdfViewerConfig.MAX_CACHE_SIZE
302
+ val RENDERING_TIMEOUT_MS = PdfViewerConfig.RENDERING_TIMEOUT_MS
303
+
304
+ // ========================================================================
305
+ // SECTION 2.8: DYNAMIC ZOOM STEP SIZES BASED ON SCREEN SIZE
306
+ // ========================================================================
307
+
308
+ /**
309
+ * Calculate zoom step sizes based on screen width percentage
310
+ * - Full screen (100%): Smaller steps (5%) for precise control
311
+ * - Split screen (<100%): Larger steps proportional to maxZoom for efficient navigation
312
+ *
313
+ * The step size is calculated as a percentage of maxZoom to maintain proportional scaling:
314
+ * - Full screen: step = 0.6f * 0.0833 = 0.05f (5%)
315
+ * - Split screen: step = 2.5f * 0.04f = 0.10f (10%) - capped for usability
316
+ */
317
+ val zoomInStep = if (screenWidthPercentage >= 100f) {
318
+ // Full screen: use standard 5% step (8.33% of maxZoom 0.6f)
319
+ 0.05f
320
+ } else {
321
+ // Split screen: calculate step as percentage of maxZoom, but cap at reasonable value
322
+ // Use 4% of maxZoom, but ensure minimum 0.10f and maximum 0.15f for usability
323
+ val calculatedStep = maxZoom * 0.04f
324
+ calculatedStep.coerceIn(0.10f, 0.15f)
325
+ }
326
+
327
+ val zoomOutStep = zoomInStep // Use same step size for both directions
328
+
329
+ // ========================================================================
330
+ // SECTION 3: HELPER FUNCTIONS
331
+ // ========================================================================
332
+
333
+ /**
334
+ * Safely switches to Main thread with proper cancellation handling
335
+ *
336
+ * This function handles LeftCompositionCancellationException which is
337
+ * expected when a composable leaves composition. It's not an error.
338
+ *
339
+ * @param block The code block to execute on Main thread
340
+ * @return The result of the block, or null if cancelled/errored
341
+ */
342
+ suspend fun <T> safeMainContext(block: suspend () -> T): T? {
343
+ return try {
344
+ withContext(Dispatchers.Main) {
345
+ block()
346
+ }
347
+ } catch (e: kotlinx.coroutines.CancellationException) {
348
+ // Check if it's a LeftCompositionCancellationException (expected when composable leaves)
349
+ if (e.javaClass.simpleName == "LeftCompositionCancellationException") {
350
+ // Expected when composable leaves composition - not an error
351
+ }
352
+ throw e
353
+ } catch (e: Exception) {
354
+ null
355
+ }
356
+ }
357
+
358
+ /**
359
+ * Requests the UI layer to re-queue currently visible pages.
360
+ *
361
+ * This is triggered after aggressive cache cleanups (usually at max zoom) to make
362
+ * sure the pages that just got evicted are scheduled for rendering again.
363
+ */
364
+ suspend fun requestVisiblePagesReload(reason: String) {
365
+ try {
366
+ safeMainContext {
367
+ reloadVisiblePagesToken = reloadVisiblePagesToken + 1
368
+ }
369
+ } catch (e: Exception) {
370
+ }
371
+ }
372
+
373
+ // ========================================================================
374
+ // SECTION 4: PAGE RENDERING SYSTEM
375
+ // ========================================================================
376
+
377
+ /**
378
+ * Internal rendering function - renders a single PDF page to a Bitmap
379
+ *
380
+ * This function is called by the render worker. It handles:
381
+ * - Page validation
382
+ * - Thread-safe page opening (using mutex)
383
+ * - Bitmap creation with memory management
384
+ * - Error handling and cleanup
385
+ * - Performance timing tracking
386
+ *
387
+ * @param pageIndex The index of the page to render (0-based)
388
+ * @param targetScale The scale at which to render (1.0f minimum, up to maxZoom)
389
+ * @return The rendered Bitmap, or null if rendering failed
390
+ */
391
+ suspend fun renderPageInternal(pageIndex: Int, targetScale: Float): Bitmap? = withContext(Dispatchers.Default) {
392
+ // Use the optimized rendering function with timing
393
+ val result = renderPageToBitmap(
394
+ pageIndex = pageIndex,
395
+ targetScale = targetScale,
396
+ pageCount = pageCount,
397
+ pdfRenderer = pdfRenderer,
398
+ renderMutex = renderMutex,
399
+ density = density,
400
+ activeBitmaps = activeBitmaps,
401
+ logTimings = true // Enable detailed timing logs
402
+ )
403
+
404
+ // Add timing to statistics if rendering was successful
405
+ result?.let { (bitmap, timings) ->
406
+ renderingStats.addTiming(timings)
407
+
408
+ // Log aggregated stats every 10 pages
409
+ if (renderingStats.getStats().contains("10")) {
410
+ Log.i("PdfRenderer", renderingStats.getStats())
411
+ }
412
+
413
+ bitmap
414
+ }
415
+ }
416
+
417
+ // ========================================================================
418
+ // SECTION 5: RENDER WORKER AND WATCHDOG
419
+ // ========================================================================
420
+
421
+ /**
422
+ * Watchdog - Monitors and clears stuck pages in renderingPages
423
+ *
424
+ * Checks every 2 seconds for pages that have been "rendering" for too long
425
+ * (longer than RENDERING_TIMEOUT_MS). These are likely stuck and need to be cleared.
426
+ */
427
+ LaunchedEffect(Unit) {
428
+ launch(Dispatchers.Default) {
429
+ while (true) {
430
+ try {
431
+ delay(100) // Check every 2 seconds
432
+ val currentTime = System.currentTimeMillis()
433
+ val stuckPages = mutableListOf<Int>()
434
+
435
+ // Check for stuck pages in renderingPages
436
+ renderingPages.forEach { (pageIndex, startTime) ->
437
+ if (currentTime - startTime > RENDERING_TIMEOUT_MS) {
438
+ stuckPages.add(pageIndex)
439
+ }
440
+ }
441
+
442
+ // Clear stuck pages from both renderingPages and queuedPages
443
+ if (stuckPages.isNotEmpty()) {
444
+ stuckPages.forEach { pageIndex ->
445
+ renderingPages.remove(pageIndex)
446
+ }
447
+
448
+ try {
449
+ withContext(Dispatchers.Main) {
450
+ val currentQueued = queuedPages.value
451
+ queuedPages.value = currentQueued - stuckPages.toSet()
452
+ }
453
+ } catch (e: kotlinx.coroutines.CancellationException) {
454
+ // Check if it's a LeftCompositionCancellationException (expected when composable leaves)
455
+ if (e.javaClass.simpleName == "LeftCompositionCancellationException") {
456
+ // Expected when composable leaves composition - not an error
457
+ } else {
458
+ // Other cancellation - also expected
459
+ }
460
+ throw e
461
+ }
462
+
463
+ }
464
+ } catch (e: kotlinx.coroutines.CancellationException) {
465
+ // Check if it's a LeftCompositionCancellationException (expected when composable leaves)
466
+ if (e.javaClass.simpleName == "LeftCompositionCancellationException") {
467
+ // Expected when composable leaves composition - not an error
468
+ } else {
469
+ // Other cancellation - also expected
470
+ }
471
+ throw e
472
+ } catch (e: Exception) {
473
+ // Only log actual errors, not cancellations
474
+ e.printStackTrace()
475
+ }
476
+ }
477
+ }
478
+ }
479
+
480
+ /**
481
+ * Render Worker - Processes render requests sequentially
482
+ *
483
+ * This worker processes render requests from the queue one at a time.
484
+ * It handles:
485
+ * - Request validation
486
+ * - Stale request detection (scale changed)
487
+ * - Memory management
488
+ * - Cache updates
489
+ * - Bitmap recycling
490
+ */
491
+ LaunchedEffect(Unit) {
492
+ launch(Dispatchers.Default) {
493
+
494
+ try {
495
+ for (request in renderQueue) {
496
+ var pageIndex: Int? = null
497
+ try {
498
+ pageIndex = request.pageIndex
499
+
500
+ // Check if PDF renderer is still valid
501
+ if (pdfRenderer == null) {
502
+ renderingPages.remove(request.pageIndex)
503
+ safeMainContext {
504
+ queuedPages.value = queuedPages.value - request.pageIndex
505
+ }
506
+ continue
507
+ }
508
+
509
+ // Validate page index
510
+ if (request.pageIndex < 0 || request.pageIndex >= pageCount) {
511
+ renderingPages.remove(request.pageIndex)
512
+ safeMainContext {
513
+ queuedPages.value = queuedPages.value - request.pageIndex
514
+ }
515
+ continue
516
+ }
517
+
518
+ // CRITICAL FIX: Don't skip rendering during pinch zoom - allow pages to render
519
+ // Only skip during button zoom (isZoomInProgress) to avoid conflicts
520
+ // During pinch zoom, pages should render to prevent white screen
521
+ val zoomActive = safeMainContext { isZoomInProgress } ?: false
522
+ val pinchActive = safeMainContext { isPinchZoomActive } ?: false
523
+ val recentPinch = safeMainContext { recentPinchZoom } ?: false
524
+
525
+ // Skip only if button zoom is active (not pinch zoom)
526
+ if (zoomActive && !pinchActive && !recentPinch) {
527
+ renderingPages.remove(request.pageIndex)
528
+ safeMainContext {
529
+ queuedPages.value = queuedPages.value - request.pageIndex
530
+ }
531
+ continue
532
+ }
533
+
534
+ // Check if scale has changed significantly since request was queued
535
+ // CRITICAL FIX: Reduced threshold from 10% to 5% to catch stale renders faster
536
+ // This prevents rendering pages at stale scales during rapid zoom changes
537
+ // CRITICAL FIX: During pinch zoom, be more lenient with stale render detection
538
+ // This allows pages to render during pinch zoom even if scale changes slightly
539
+ val currentRenderedScale = safeMainContext { lastRenderedScale } ?: request.targetScale
540
+ val currentScale = safeMainContext { scale } ?: request.targetScale
541
+ val scaleDiff = kotlin.math.abs(currentRenderedScale - request.targetScale)
542
+ val currentScaleDiff = kotlin.math.abs(currentScale - request.targetScale)
543
+
544
+ // During pinch zoom, use current scale instead of lastRenderedScale for stale detection
545
+ // This allows rendering during pinch zoom even if lastRenderedScale hasn't updated yet
546
+ val effectiveScaleDiff = if (pinchActive || recentPinch) {
547
+ currentScaleDiff // Use current scale during pinch zoom
548
+ } else {
549
+ scaleDiff // Use lastRenderedScale for normal zoom
550
+ }
551
+
552
+ // More lenient threshold during pinch zoom
553
+ val staleThreshold = if (pinchActive || recentPinch) {
554
+ 0.15f // 15% threshold during pinch zoom (more lenient)
555
+ } else {
556
+ 0.05f // 5% threshold for normal zoom
557
+ }
558
+
559
+ if (effectiveScaleDiff > staleThreshold) {
560
+ renderingPages.remove(request.pageIndex)
561
+ safeMainContext {
562
+ queuedPages.value = queuedPages.value - request.pageIndex
563
+ }
564
+ continue
565
+ }
566
+
567
+ // Skip if already cached at this scale (with error handling)
568
+ val cached = safeMainContext { pageCache[request.pageIndex] }
569
+ if (cached != null && kotlin.math.abs(cached.second - request.targetScale) < 0.08f) {
570
+ // Remove from queued if it was there
571
+ safeMainContext {
572
+ queuedPages.value = queuedPages.value - request.pageIndex
573
+ }
574
+ continue
575
+ }
576
+
577
+ // Check if page is being rendered - but allow stale entries to be re-rendered
578
+ val renderStartTime = renderingPages[request.pageIndex]
579
+ val currentTime = System.currentTimeMillis()
580
+ val isStale = renderStartTime != null && (currentTime - renderStartTime) > RENDERING_TIMEOUT_MS
581
+
582
+ if (renderStartTime != null && !isStale) {
583
+ // Remove from queued if it was there
584
+ try {
585
+ withContext(Dispatchers.Main) {
586
+ queuedPages.value = queuedPages.value - request.pageIndex
587
+ }
588
+ } catch (e: Exception) {
589
+ }
590
+ continue
591
+ }
592
+
593
+ if (isStale && renderStartTime != null) {
594
+ renderingPages.remove(request.pageIndex) // Clear stale entry
595
+ }
596
+
597
+ // Check memory before rendering
598
+ val runtime = Runtime.getRuntime()
599
+ val usedMemory = runtime.totalMemory() - runtime.freeMemory()
600
+ val maxMemory = runtime.maxMemory()
601
+ val memoryUsagePercent = (usedMemory * 100 / maxMemory).toInt()
602
+
603
+ // CRITICAL FIX: At max zoom, be more conservative with memory thresholds
604
+ // Max zoom creates much larger bitmaps, so we need higher thresholds
605
+ // Reuse currentScale from above (line 568) to avoid duplicate declaration
606
+ val isMaxZoom = currentScale > 1.0f
607
+
608
+ // Adjust memory threshold based on zoom level
609
+ // At max zoom, allow higher memory usage before aggressive cleanup
610
+ val memoryThreshold = if (isMaxZoom) {
611
+ 85 // Higher threshold for max zoom (was 75)
612
+ } else {
613
+ 75 // Normal threshold for regular zoom
614
+ }
615
+
616
+ // If memory is high, clean cache before rendering
617
+ if (memoryUsagePercent > memoryThreshold) {
618
+
619
+ // CRITICAL FIX: At max zoom, preserve pages at current scale to prevent white screen
620
+ // Keep visible pages and pages at current scale, not just batches
621
+ val currentBatch = request.pageIndex / batchSize
622
+ val batchesToKeep = setOf(currentBatch - 1, currentBatch, currentBatch + 1)
623
+ .filter { batchNum -> batchNum >= 0 && batchNum * batchSize < pageCount }
624
+
625
+ try {
626
+ // CRITICAL: Collect bitmaps to recycle BEFORE updating cache
627
+ val bitmapsToRecycle = mutableListOf<Bitmap>()
628
+
629
+ withContext(Dispatchers.Main) {
630
+ // CRITICAL FIX: At max zoom, preserve pages at current scale
631
+ // This prevents white screen when scrolling at max zoom
632
+ val pagesToKeep = if (isMaxZoom) {
633
+ pageCache.filter { (pageIndex, pair) ->
634
+ val pageBatch = pageIndex / batchSize
635
+ val bitmapScale = pair.second
636
+ val scaleDiff = kotlin.math.abs(bitmapScale - currentScale)
637
+
638
+ // Keep pages in nearby batches OR pages at current scale (max zoom)
639
+ batchesToKeep.contains(pageBatch) || scaleDiff < 0.50f
640
+ }
641
+ } else {
642
+ // Normal zoom: just keep nearby batches
643
+ pageCache.filter { (pageIndex, _) ->
644
+ val pageBatch = pageIndex / batchSize
645
+ batchesToKeep.contains(pageBatch)
646
+ }
647
+ }
648
+
649
+ // Collect bitmaps that will be removed (but don't recycle yet)
650
+ pageCache.forEach { (pageIndex, pair) ->
651
+ if (!pagesToKeep.containsKey(pageIndex)) {
652
+ bitmapsToRecycle.add(pair.first)
653
+ }
654
+ }
655
+
656
+ // CRITICAL: Update cache FIRST
657
+ pageCache = pagesToKeep
658
+ }
659
+
660
+ // CRITICAL: Wait to ensure UI has stopped using removed bitmaps
661
+ delay(100)
662
+
663
+ // CRITICAL: Now safely recycle removed bitmaps AFTER cache update
664
+ bitmapsToRecycle.forEach { bitmap ->
665
+ try {
666
+ // Double-check bitmap is not still in cache
667
+ val stillInCache = withContext(Dispatchers.Main) {
668
+ pageCache.values.any { it.first == bitmap }
669
+ }
670
+
671
+ if (stillInCache) {
672
+ return@forEach
673
+ }
674
+
675
+ activeBitmaps.remove(bitmap)
676
+ if (!bitmap.isRecycled && bitmap.width > 0 && bitmap.height > 0) {
677
+ bitmap.recycle()
678
+ }
679
+ } catch (e: Exception) {
680
+ }
681
+ }
682
+ } catch (e: Exception) {
683
+ }
684
+ System.gc()
685
+ if (isMaxZoom) {
686
+ requestVisiblePagesReload("RenderWorkerCacheCleanup")
687
+ }
688
+ }
689
+
690
+ // Remove from queued and mark as rendering with timestamp
691
+ try {
692
+ withContext(Dispatchers.Main) {
693
+ queuedPages.value = queuedPages.value - request.pageIndex
694
+ }
695
+ } catch (e: Exception) {
696
+ }
697
+
698
+ renderingPages[request.pageIndex] = System.currentTimeMillis()
699
+
700
+ var bitmap: Bitmap? = null
701
+ var renderSuccess = false
702
+
703
+ try {
704
+ bitmap = renderPageInternal(request.pageIndex, request.targetScale)
705
+ } catch (e: OutOfMemoryError) {
706
+ // Clean cache aggressively
707
+ try {
708
+ withContext(Dispatchers.Main) {
709
+ val currentBatch = request.pageIndex / batchSize
710
+ val batchesToKeep = setOf(currentBatch).filter { batchNum -> batchNum >= 0 }
711
+ pageCache = pageCache.filter { (idx, _) ->
712
+ val pageBatch = idx / batchSize
713
+ batchesToKeep.contains(pageBatch)
714
+ }
715
+ }
716
+ } catch (ex: Exception) {
717
+ }
718
+ System.gc()
719
+ delay(100)
720
+ bitmap = null // Return null, page will be retried later
721
+ } catch (e: Exception) {
722
+ e.printStackTrace()
723
+ bitmap = null
724
+ }
725
+
726
+ if (bitmap != null) {
727
+ // CRITICAL: Store reference to old bitmap BEFORE updating cache
728
+ val oldCachedBitmap = cached?.first
729
+
730
+ // CRITICAL FIX: Check if pinch zoom is active before recycling old bitmap
731
+ // During pinch zoom, keep old bitmap to prevent white screen
732
+ val pinchActive = safeMainContext { isPinchZoomActive } ?: false
733
+ val recentPinch = safeMainContext { recentPinchZoom } ?: false
734
+
735
+ // CRITICAL: Update cache FIRST on main thread
736
+ // This ensures UI switches to new bitmap before old one is recycled
737
+ try {
738
+ withContext(Dispatchers.Main) {
739
+ pageCache = pageCache.toMutableMap().apply {
740
+ put(request.pageIndex, bitmap!! to request.targetScale)
741
+ }
742
+ }
743
+ renderSuccess = true
744
+ } catch (e: Exception) {
745
+ // Still mark as success since bitmap was created
746
+ renderSuccess = true
747
+ }
748
+
749
+ // CRITICAL FIX: During pinch zoom or recent pinch zoom, DON'T recycle old bitmap immediately
750
+ // Keep it available for visual scaling to prevent white screen
751
+ if (!pinchActive && !recentPinch) {
752
+ // CRITICAL: Wait a bit to ensure UI has switched to new bitmap
753
+ delay(50)
754
+
755
+ // CRITICAL: Now safely recycle old bitmap AFTER cache update
756
+ if (oldCachedBitmap != null) {
757
+ try {
758
+ // Double-check old bitmap is not still in cache
759
+ val stillInCache = withContext(Dispatchers.Main) {
760
+ pageCache[request.pageIndex]?.first == oldCachedBitmap
761
+ }
762
+
763
+ if (stillInCache) {
764
+ } else {
765
+ // Remove from active bitmaps
766
+ activeBitmaps.remove(oldCachedBitmap)
767
+
768
+ // Final validation before recycling
769
+ if (!oldCachedBitmap.isRecycled && oldCachedBitmap.width > 0 && oldCachedBitmap.height > 0) {
770
+ oldCachedBitmap.recycle()
771
+ } else {
772
+ }
773
+ }
774
+ } catch (e: Exception) {
775
+ }
776
+ }
777
+ } else {
778
+ // During pinch zoom, keep old bitmap for visual scaling
779
+ }
780
+ }
781
+
782
+ // ALWAYS remove from renderingPages, even on error
783
+ renderingPages.remove(request.pageIndex)
784
+ if (!renderSuccess) {
785
+ // Remove from queued if render failed
786
+ try {
787
+ withContext(Dispatchers.Main) {
788
+ queuedPages.value = queuedPages.value - request.pageIndex
789
+ }
790
+ } catch (e: Exception) {
791
+ }
792
+ }
793
+
794
+ // Small delay to prevent CPU hogging
795
+ delay(5)
796
+
797
+ } catch (e: kotlinx.coroutines.CancellationException) {
798
+ // Clean up on cancellation
799
+ if (pageIndex != null) {
800
+ renderingPages.remove(pageIndex)
801
+ }
802
+ throw e // Re-throw cancellation
803
+ } catch (e: Exception) {
804
+ e.printStackTrace()
805
+ // Ensure cleanup even if outer try fails
806
+ if (pageIndex != null) {
807
+ renderingPages.remove(pageIndex)
808
+ try {
809
+ withContext(Dispatchers.Main) {
810
+ queuedPages.value = queuedPages.value - pageIndex
811
+ }
812
+ } catch (ex: Exception) {
813
+ }
814
+ }
815
+ }
816
+ }
817
+ } catch (e: kotlinx.coroutines.CancellationException) {
818
+ throw e
819
+ } catch (e: Exception) {
820
+ e.printStackTrace()
821
+ }
822
+ }
823
+ }
824
+
825
+ // ========================================================================
826
+ // SECTION 6: CACHE AND BATCH MANAGEMENT FUNCTIONS
827
+ // ========================================================================
828
+
829
+ /**
830
+ * Calculates which batch a page belongs to
831
+ * Uses helper function from PdfCacheManager
832
+ */
833
+ fun getBatchNumber(pageIndex: Int): Int {
834
+ return getBatchNumber(pageIndex, batchSize)
835
+ }
836
+
837
+ /**
838
+ * Gets the page range for a specific batch
839
+ * Uses helper function from PdfCacheManager
840
+ */
841
+ fun getBatchRange(batchNumber: Int): IntRange {
842
+ return getBatchRange(batchNumber, batchSize, pageCount)
843
+ }
844
+
845
+ /**
846
+ * Clears the render queue and all rendering states
847
+ *
848
+ * Called when scroll/zoom changes to prevent stale renders.
849
+ * Clears:
850
+ * - Render queue (pending requests)
851
+ * - Queued pages tracking
852
+ * - Rendering pages tracking
853
+ */
854
+ @OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class)
855
+ suspend fun clearRenderQueue() = withContext(Dispatchers.Default) {
856
+ var cleared = 0
857
+ try {
858
+ while (!renderQueue.isEmpty) {
859
+ renderQueue.tryReceive().getOrNull()?.let { cleared++ }
860
+ }
861
+ } catch (e: kotlinx.coroutines.CancellationException) {
862
+ // Check if it's a LeftCompositionCancellationException (expected when composable leaves)
863
+ if (e.javaClass.simpleName == "LeftCompositionCancellationException") {
864
+ // Expected when composable leaves composition - not an error
865
+ } else {
866
+ // Other cancellation - also expected
867
+ }
868
+ throw e
869
+ } catch (e: Exception) {
870
+ // Only log actual errors
871
+ }
872
+
873
+ // Clear queued pages tracking and stuck rendering pages
874
+ try {
875
+ withContext(Dispatchers.Main) {
876
+ queuedPages.value = emptySet()
877
+ }
878
+ } catch (e: kotlinx.coroutines.CancellationException) {
879
+ // Check if it's a LeftCompositionCancellationException (expected when composable leaves)
880
+ if (e.javaClass.simpleName == "LeftCompositionCancellationException") {
881
+ // Expected when composable leaves composition - not an error
882
+ } else {
883
+ // Other cancellation - also expected
884
+ }
885
+ throw e
886
+ } catch (e: Exception) {
887
+ // Only log actual errors
888
+ }
889
+
890
+ // Also clear renderingPages to prevent stuck states
891
+ renderingPages.clear()
892
+
893
+ if (cleared > 0) {
894
+ }
895
+ }
896
+
897
+ /**
898
+ * Loads a batch of pages by submitting them to the render queue
899
+ *
900
+ * This function queues pages for rendering but doesn't render them directly.
901
+ * The render worker processes them sequentially.
902
+ *
903
+ * @param batchNumber The batch number to load
904
+ * @param targetScale The scale at which to render pages
905
+ * @param priority Priority level (0=highest, 1=medium, 2=low)
906
+ */
907
+ suspend fun loadBatch(batchNumber: Int, targetScale: Float, priority: Int = 2) = withContext(Dispatchers.Default) {
908
+ if (loadedBatches.contains(batchNumber)) {
909
+ return@withContext
910
+ }
911
+
912
+ val range = getBatchRange(batchNumber)
913
+
914
+ // Submit each page to the render queue (worker will process them smoothly)
915
+ for (pageIndex in range) {
916
+ // Skip if already in cache at correct scale
917
+ val cached = pageCache[pageIndex]
918
+ val needsRender = cached == null ||
919
+ kotlin.math.abs(cached.second - targetScale) > 0.08f
920
+
921
+ if (needsRender) {
922
+ val request = RenderRequest(pageIndex, targetScale, priority)
923
+ renderQueue.send(request) // Non-blocking send to queue
924
+
925
+ // Track queued pages
926
+ withContext(Dispatchers.Main) {
927
+ queuedPages.value = queuedPages.value + pageIndex
928
+ }
929
+
930
+ }
931
+ }
932
+
933
+ // Mark batch as loaded (pages are now queued for rendering)
934
+ withContext(Dispatchers.Main) {
935
+ loadedBatches = loadedBatches + batchNumber
936
+ }
937
+
938
+ }
939
+
940
+ /**
941
+ * Manages cache size by removing batches far from current position
942
+ *
943
+ * Keeps only nearby batches (current ± 1) in memory to prevent OOM.
944
+ * More aggressive cleanup if memory usage is high.
945
+ *
946
+ * @param currentVisiblePage The currently visible page index
947
+ */
948
+ suspend fun manageCacheSize(currentVisiblePage: Int) = withContext(Dispatchers.Default) {
949
+ // Check memory usage and cache size
950
+ val runtime = Runtime.getRuntime()
951
+ val usedMemory = runtime.totalMemory() - runtime.freeMemory()
952
+ val maxMemory = runtime.maxMemory()
953
+ val memoryUsagePercent = (usedMemory * 100 / maxMemory).toInt()
954
+
955
+ // CRITICAL FIX: At max zoom, be more conservative with cache management
956
+ // Max zoom creates much larger bitmaps, so we need to preserve more pages
957
+ val currentScale = safeMainContext { scale } ?: MIN_ZOOM
958
+ val isMaxZoom = currentScale > 1.0f
959
+
960
+ // More aggressive cache management if memory is high
961
+ // But at max zoom, be more lenient to prevent white screen
962
+ val effectiveCacheSize = if (isMaxZoom) {
963
+ // At max zoom, preserve more pages to prevent white screen
964
+ if (memoryUsagePercent > 80) {
965
+ (maxCacheSize * 2 / 3).toInt() // Only reduce to 2/3 if memory > 80%
966
+ } else if (memoryUsagePercent > 70) {
967
+ (maxCacheSize * 3 / 4).toInt() // Reduce to 3/4 if memory > 70%
968
+ } else {
969
+ maxCacheSize // Keep full cache if memory < 70%
970
+ }
971
+ } else {
972
+ // Normal zoom: use original logic
973
+ if (memoryUsagePercent > 60) {
974
+ maxCacheSize / 2 // Reduce cache size by half if memory > 60%
975
+ } else if (memoryUsagePercent > 50) {
976
+ (maxCacheSize * 2 / 3).toInt() // Reduce to 2/3 if memory > 50%
977
+ } else {
978
+ maxCacheSize
979
+ }
980
+ }
981
+
982
+ // CRITICAL FIX: At max zoom, use higher memory threshold before aggressive cleanup
983
+ val memoryThreshold = if (isMaxZoom) {
984
+ 80 // Higher threshold for max zoom (was 70)
985
+ } else {
986
+ 70 // Normal threshold
987
+ }
988
+
989
+ if (pageCache.size > effectiveCacheSize || memoryUsagePercent > memoryThreshold) {
990
+ try {
991
+ val currentBatch = getBatchNumber(currentVisiblePage)
992
+
993
+ // Keep current batch and 1 batch before/after (3 batches total = 60 pages)
994
+ val batchesToKeep = setOf(
995
+ currentBatch - 1,
996
+ currentBatch,
997
+ currentBatch + 1
998
+ ).filter { it >= 0 && it * batchSize < pageCount }
999
+
1000
+ val pagesToKeep = mutableMapOf<Int, Pair<Bitmap, Float>>()
1001
+ val pagesToRemove = mutableListOf<Bitmap>()
1002
+ val batchesToUnload = mutableSetOf<Int>()
1003
+
1004
+ // CRITICAL FIX: Get current scale to preserve pages at max zoom AND minimum zoom
1005
+ val currentScale = safeMainContext { scale } ?: MIN_ZOOM
1006
+ val scaleDiffThreshold = when {
1007
+ currentScale > 1.0f -> 0.50f // For max zoom, be more lenient (50% difference)
1008
+ currentScale <= 0.35f -> 0.50f // For minimum zoom, also be lenient (keeps pages at higher scales)
1009
+ currentScale >= 1.0f && currentScale <= 1.5f -> 0.50f // CRITICAL FIX: For moderate zoom (135-150%), be lenient to prevent white screen
1010
+ else -> 0.10f // For normal zoom, use 10% threshold
1011
+ }
1012
+
1013
+ pageCache.forEach { (pageIndex, pair) ->
1014
+ val pageBatch = getBatchNumber(pageIndex)
1015
+ val bitmapScale = pair.second
1016
+ val scaleDiff = kotlin.math.abs(bitmapScale - currentScale)
1017
+
1018
+ // Keep pages if they're in nearby batches OR if they're at the current scale (max zoom)
1019
+ // This prevents clearing pages at max zoom when scrolling
1020
+ if (batchesToKeep.contains(pageBatch) || scaleDiff < scaleDiffThreshold) {
1021
+ pagesToKeep[pageIndex] = pair
1022
+ } else {
1023
+ pagesToRemove.add(pair.first)
1024
+ batchesToUnload.add(pageBatch)
1025
+ }
1026
+ }
1027
+
1028
+ // CRITICAL: Update cache on main thread FIRST, then wait before recycling
1029
+ // This ensures UI stops using bitmaps before they're recycled
1030
+ try {
1031
+ safeMainContext {
1032
+ pageCache = pagesToKeep
1033
+ loadedBatches = loadedBatches.filter { batchesToKeep.contains(it) }.toSet()
1034
+ if (isMaxZoom) {
1035
+ reloadVisiblePagesToken = reloadVisiblePagesToken + 1
1036
+ }
1037
+ }
1038
+ } catch (e: kotlinx.coroutines.CancellationException) {
1039
+ // Expected cancellation - re-throw to propagate
1040
+ throw e
1041
+ }
1042
+
1043
+ // CRITICAL: Longer delay to ensure UI has stopped using removed bitmaps
1044
+ // This prevents race condition where bitmap is recycled while still being drawn
1045
+ try {
1046
+ delay(300)
1047
+ } catch (e: kotlinx.coroutines.CancellationException) {
1048
+ // Expected cancellation - re-throw to propagate
1049
+ throw e
1050
+ }
1051
+
1052
+ // Safely recycle removed bitmaps - double check they're not in cache
1053
+ pagesToRemove.forEach { bitmap ->
1054
+ try {
1055
+ // CRITICAL: Check if bitmap is still in cache (shouldn't be, but double-check)
1056
+ val stillInCache = safeMainContext {
1057
+ pageCache.values.any { it.first == bitmap }
1058
+ } ?: false
1059
+
1060
+ if (stillInCache) {
1061
+ return@forEach
1062
+ }
1063
+
1064
+ // Don't recycle if still in active use
1065
+ if (activeBitmaps.contains(bitmap)) {
1066
+ return@forEach
1067
+ }
1068
+
1069
+ // Final check before recycling
1070
+ if (!bitmap.isRecycled && bitmap.width > 0 && bitmap.height > 0) {
1071
+ bitmap.recycle()
1072
+ } else {
1073
+ }
1074
+ } catch (e: Exception) {
1075
+ }
1076
+ }
1077
+
1078
+ } catch (e: kotlinx.coroutines.CancellationException) {
1079
+ // Check if it's a LeftCompositionCancellationException (expected when composable leaves)
1080
+ if (e.javaClass.simpleName == "LeftCompositionCancellationException") {
1081
+ // Expected when composable leaves composition - not an error
1082
+ } else {
1083
+ // Other cancellation - also expected
1084
+ }
1085
+ throw e
1086
+ } catch (e: Exception) {
1087
+ // Only log actual errors, not cancellations
1088
+ e.printStackTrace()
1089
+ }
1090
+ }
1091
+ }
1092
+
1093
+ // ========================================================================
1094
+ // SECTION 7: CLEANUP AND RESOURCE MANAGEMENT
1095
+ // ========================================================================
1096
+
1097
+ /**
1098
+ * Cleanup - Recycles bitmaps and closes PDF renderer when composable is disposed
1099
+ *
1100
+ * This ensures proper resource cleanup when the viewer is closed.
1101
+ */
1102
+ DisposableEffect(Unit) {
1103
+ onDispose {
1104
+
1105
+ // Close render queue
1106
+ try {
1107
+ renderQueue.close()
1108
+ } catch (e: Exception) {
1109
+ e.printStackTrace()
1110
+ }
1111
+
1112
+ // Recycle all cached bitmaps
1113
+ pageCache.values.forEach { (bitmap, _) ->
1114
+ try {
1115
+ activeBitmaps.remove(bitmap)
1116
+ if (!bitmap.isRecycled) {
1117
+ bitmap.recycle()
1118
+ }
1119
+ } catch (e: Exception) {
1120
+ e.printStackTrace()
1121
+ }
1122
+ }
1123
+
1124
+ // Recycle any remaining active bitmaps
1125
+ activeBitmaps.forEach { bitmap ->
1126
+ try {
1127
+ if (!bitmap.isRecycled) {
1128
+ bitmap.recycle()
1129
+ }
1130
+ } catch (e: Exception) {
1131
+ e.printStackTrace()
1132
+ }
1133
+ }
1134
+ activeBitmaps.clear()
1135
+ renderingPages.clear()
1136
+
1137
+ // Close PDF renderer and file descriptor
1138
+ try {
1139
+ pdfRenderer?.close()
1140
+ pdfRenderer = null
1141
+ } catch (e: Exception) {
1142
+ e.printStackTrace()
1143
+ }
1144
+
1145
+ try {
1146
+ pfd?.close()
1147
+ pfd = null
1148
+ } catch (e: Exception) {
1149
+ e.printStackTrace()
1150
+ }
1151
+
1152
+ }
1153
+ }
1154
+
1155
+ // ========================================================================
1156
+ // SECTION 8: PDF INITIALIZATION AND LOADING
1157
+ // ========================================================================
1158
+
1159
+ /**
1160
+ * Initial PDF Load - Opens PDF and pre-loads first few batches
1161
+ *
1162
+ * This LaunchedEffect runs when the URI changes. It:
1163
+ * - Opens the PDF file
1164
+ * - Creates the PdfRenderer
1165
+ * - Pre-loads first 4 batches (60 pages) for smooth initial experience
1166
+ * - Switches to on-demand loading for remaining pages
1167
+ */
1168
+ LaunchedEffect(uri) {
1169
+ isInitialLoading = true
1170
+ loadingProgress = 0
1171
+ loadingStatus = "Opening PDF..."
1172
+
1173
+ try {
1174
+ val contentResolver = context.contentResolver
1175
+ val tempPfd = contentResolver.openFileDescriptor(uri, "r")
1176
+
1177
+ if (tempPfd == null) {
1178
+ Log.e("PdfRenderer", "Failed to open PDF file")
1179
+ isInitialLoading = false
1180
+ return@LaunchedEffect
1181
+ }
1182
+
1183
+ // Store PDF renderer and file descriptor
1184
+ pfd = tempPfd
1185
+ pdfRenderer = PdfRenderer(tempPfd)
1186
+ pageCount = pdfRenderer!!.pageCount
1187
+
1188
+ loadingStatus = "Preparing PDF..."
1189
+ // Initialize lastRenderedScale to default scale (100%)
1190
+ lastRenderedScale = scale.coerceIn(0.3f, maxZoom)
1191
+
1192
+ // Hybrid approach: Pre-load first few batches, then load rest on-demand
1193
+ // This prevents OOM for large PDFs while still providing smooth initial experience
1194
+ withContext(Dispatchers.Default) {
1195
+ val totalBatches = (pageCount + batchSize - 1) / batchSize
1196
+ val initialBatchesToLoad = minOf(4, totalBatches) // Pre-load first 4 batches (60 pages max)
1197
+
1198
+
1199
+ // Pre-load initial batches sequentially
1200
+ for (batchNum in 0 until initialBatchesToLoad) {
1201
+ val batchStart = batchNum * batchSize
1202
+ val batchEnd = minOf((batchNum + 1) * batchSize - 1, pageCount - 1)
1203
+
1204
+ loadingStatus = "Loading pages ${batchStart + 1}-${batchEnd + 1}..."
1205
+ loadingProgress = ((batchNum + 1) * 100 / initialBatchesToLoad).coerceAtMost(90)
1206
+
1207
+
1208
+ // Load this batch
1209
+ loadBatch(batchNum, scale, priority = 0)
1210
+
1211
+ // Wait for batch to complete
1212
+ var batchComplete = false
1213
+ var attempts = 0
1214
+ while (!batchComplete && attempts < 1000) {
1215
+ delay(10)
1216
+ val allPagesInBatchLoaded = (batchStart..batchEnd).all { pageIndex ->
1217
+ pageCache.containsKey(pageIndex)
1218
+ }
1219
+ if (allPagesInBatchLoaded) {
1220
+ batchComplete = true
1221
+ }
1222
+ attempts++
1223
+ }
1224
+
1225
+ // Check memory before loading next batch
1226
+ val runtime = Runtime.getRuntime()
1227
+ val usedMemory = runtime.totalMemory() - runtime.freeMemory()
1228
+ val maxMemory = runtime.maxMemory()
1229
+ val memoryUsagePercent = (usedMemory * 100 / maxMemory).toInt()
1230
+
1231
+
1232
+ // If memory usage is high, stop pre-loading and switch to on-demand
1233
+ if (memoryUsagePercent > 70) {
1234
+ break
1235
+ }
1236
+
1237
+ delay(50) // Small delay between batches
1238
+ }
1239
+
1240
+ // Mark initial batches as loaded
1241
+ val loadedBatchesSet = (0 until minOf(initialBatchesToLoad, totalBatches)).toSet()
1242
+ withContext(Dispatchers.Main) {
1243
+ loadedBatches = loadedBatchesSet
1244
+ }
1245
+
1246
+ loadingProgress = 95
1247
+
1248
+ // More accurate loading status
1249
+ val pagesLoaded = pageCache.size
1250
+ val totalPages = pageCount
1251
+ if (pagesLoaded < totalPages) {
1252
+ loadingStatus = "Loaded $pagesLoaded of $totalPages pages. Remaining pages will load as you scroll."
1253
+ } else {
1254
+ loadingStatus = "Ready!"
1255
+ }
1256
+
1257
+ }
1258
+
1259
+ // Small delay to show completion
1260
+ delay(100)
1261
+
1262
+ isInitialLoading = false
1263
+
1264
+ // Log loading status
1265
+ val pagesLoaded = pageCache.size
1266
+ val totalPages = pageCount
1267
+ if (pagesLoaded < totalPages) {
1268
+ Log.i("PdfRenderer", "PDF opened: $pagesLoaded of $totalPages pages loaded. Scroll to load more.")
1269
+ } else {
1270
+ Log.i("PdfRenderer", "PDF loaded: $pageCount pages ready")
1271
+ }
1272
+
1273
+ } catch (e: OutOfMemoryError) {
1274
+ e.printStackTrace()
1275
+
1276
+ // Clear cache and trigger GC
1277
+ pageCache = emptyMap()
1278
+ activeBitmaps.clear()
1279
+ renderingPages.clear()
1280
+ queuedPages.value = emptySet()
1281
+ System.gc()
1282
+
1283
+ Log.e("PdfRenderer", "Out of memory loading PDF. Switching to on-demand loading.")
1284
+
1285
+ // Still show viewer, but with on-demand loading only
1286
+ isInitialLoading = false
1287
+
1288
+ } catch (e: SecurityException) {
1289
+ e.printStackTrace()
1290
+ Log.e("PdfRenderer", "Permission denied: Cannot access PDF file", e)
1291
+ isInitialLoading = false
1292
+
1293
+ } catch (e: java.io.IOException) {
1294
+ e.printStackTrace()
1295
+ Log.e("PdfRenderer", "IO Error: Cannot read PDF file", e)
1296
+ isInitialLoading = false
1297
+
1298
+ } catch (e: Exception) {
1299
+ e.printStackTrace()
1300
+ Log.e("PdfRenderer", "Error loading PDF: ${e.message}", e)
1301
+ isInitialLoading = false
1302
+ }
1303
+ }
1304
+
1305
+ // ========================================================================
1306
+ // SECTION 9: ZOOM CHANGE HANDLING
1307
+ // ========================================================================
1308
+
1309
+ /**
1310
+ * Zoom Change Handler - Debounced to avoid constant re-rendering
1311
+ *
1312
+ * This LaunchedEffect triggers when scale changes. It:
1313
+ * - Waits for zoom gesture to complete (debouncing)
1314
+ * - Clears render queue and cache at wrong scales
1315
+ * - Updates lastRenderedScale to prevent stale renders
1316
+ * - Allows pages to reload at new scale on-demand
1317
+ *
1318
+ * Uses scale as key so previous effect is cancelled when scale changes rapidly.
1319
+ */
1320
+ LaunchedEffect(scale) {
1321
+ if (!isInitialLoading && pageCount > 0) {
1322
+ var crashContext = "ZoomHandlerStart"
1323
+ try {
1324
+
1325
+ crashContext = "ScaleValidation"
1326
+ // Validate scale values
1327
+ val currentScale = scale
1328
+ val currentLastRendered = lastRenderedScale
1329
+
1330
+ if (currentScale.isNaN() || currentScale.isInfinite() || currentScale <= 0f) {
1331
+ return@LaunchedEffect
1332
+ }
1333
+
1334
+ if (currentLastRendered.isNaN() || currentLastRendered.isInfinite() || currentLastRendered <= 0f) {
1335
+ try {
1336
+ lastRenderedScale = currentScale
1337
+ } catch (e: Exception) {
1338
+ }
1339
+ return@LaunchedEffect
1340
+ }
1341
+
1342
+ crashContext = "ScaleDifferenceCalculation"
1343
+ val scaleDifference = try {
1344
+ kotlin.math.abs(currentScale - currentLastRendered)
1345
+ } catch (e: Exception) {
1346
+ return@LaunchedEffect
1347
+ }
1348
+
1349
+ // Reduced threshold from 0.08f to 0.03f (3%) for smoother zoom transitions
1350
+ if (scaleDifference >= 0.03f) {
1351
+
1352
+ crashContext = "WaitTimeCalculation"
1353
+ // If pinch zoom is active, wait longer for gesture to complete
1354
+ // For button clicks (Zoom In/Out), use much shorter wait time for smoother, faster response
1355
+ val waitTime = try {
1356
+ if (isPinchZoomActive || recentPinchZoom) {
1357
+ 1200L // Wait 1.2 seconds for pinch gesture to complete or recent pinch zoom to settle
1358
+ } else {
1359
+ 100L // Reduced from 300ms to 100ms for button clicks - much smoother response
1360
+ }
1361
+ } catch (e: Exception) {
1362
+ 100L // Default to shorter wait for smoother response
1363
+ }
1364
+
1365
+ crashContext = "ZoomInProgressFlag"
1366
+ // CRITICAL FIX: Don't set isZoomInProgress during active pinch zoom
1367
+ // This allows pages to be displayed with visual scaling during pinch gestures
1368
+ // For button clicks, set it briefly but allow visual scaling for smooth experience
1369
+ try {
1370
+ val pinchActive = isPinchZoomActive
1371
+ val recentPinch = recentPinchZoom
1372
+ if (!pinchActive && !recentPinch) {
1373
+ // Set isZoomInProgress for button clicks, but keep it brief for smooth visual scaling
1374
+ isZoomInProgress = true
1375
+ } else {
1376
+ // Pinch zoom is active or recent - cancel button zoom handling to prevent interference
1377
+ val reason = if (pinchActive) "Pinch zoom active" else "Recent pinch zoom"
1378
+ // Don't proceed with cache clearing or other button zoom logic
1379
+ return@LaunchedEffect
1380
+ }
1381
+ } catch (e: Exception) {
1382
+ // Continue anyway
1383
+ }
1384
+
1385
+ // CRITICAL FIX: Update lastRenderedScale IMMEDIATELY to prevent stale renders
1386
+ // This ensures that any renders queued at old scales will be rejected
1387
+ crashContext = "ImmediateScaleUpdate"
1388
+ try {
1389
+ lastRenderedScale = currentScale
1390
+ } catch (e: Exception) {
1391
+ // Continue anyway
1392
+ }
1393
+
1394
+ // CRITICAL FIX: Don't clear cache during active pinch zoom OR recent pinch zoom - keep all pages for visual scaling
1395
+ // Only clear cache for button/slider zoom (not pinch)
1396
+ crashContext = "ClearWrongScaleCache"
1397
+ try {
1398
+ val pinchActive = isPinchZoomActive
1399
+ val recentPinch = recentPinchZoom
1400
+ // Triple-check: Don't clear cache if pinch zoom is active OR recent pinch zoom OR button zoom is in progress
1401
+ // This prevents interference between button and pinch zoom AND prevents white screen after pinch zoom ends
1402
+ if (!pinchActive && !recentPinch && !isZoomInProgress) {
1403
+ // Only clear cache if NOT pinch zooming AND NOT recent pinch zoom AND NOT button zooming
1404
+ val currentCache = pageCache
1405
+ val isZoomingOut = currentScale < currentLastRendered
1406
+
1407
+ val pagesToKeep = currentCache.filter { (pageIndex, pair) ->
1408
+ val bitmapScale = pair.second
1409
+ val scaleDiff = kotlin.math.abs(bitmapScale - currentScale)
1410
+
1411
+ if (isZoomingOut) {
1412
+ // Zoom out: Keep pages that are larger (can be scaled down visually) OR within reasonable range
1413
+ // CRITICAL FIX: For minimum zoom (100% = 1.0f), be more lenient to prevent white screen
1414
+ // Allow pages at any scale that can be visually scaled down to minimum zoom
1415
+ val minZoomThreshold = if (currentScale <= 1.1f) {
1416
+ 0.50f // For minimum zoom (100% = 1.0f), allow up to 50% difference (keeps pages at higher scales)
1417
+ } else {
1418
+ 0.10f // For normal zoom out, use 10% threshold
1419
+ }
1420
+ bitmapScale >= currentScale * 0.9f || scaleDiff < minZoomThreshold || bitmapScale <= currentScale * 2.0f
1421
+ } else {
1422
+ // Zoom in: Keep pages that can be scaled up visually OR within reasonable range
1423
+ // For max zoom (e.g., 2.5f) and moderate zoom (135-150%), keep pages even if scale difference is large
1424
+ // This prevents white screen when zooming to max or moderate zoom levels
1425
+ val maxScaleDiff = when {
1426
+ currentScale > 1.5f -> 0.50f // For max zoom (>150%), be more lenient (50% difference allowed)
1427
+ currentScale >= 1.0f && currentScale <= 1.5f -> 0.50f // CRITICAL FIX: For moderate zoom (135-150%), be lenient to prevent white screen
1428
+ else -> 0.05f // For normal zoom, strict (5% difference)
1429
+ }
1430
+ scaleDiff < maxScaleDiff || bitmapScale <= currentScale * 1.5f
1431
+ }
1432
+ }
1433
+
1434
+ if (pagesToKeep.size < currentCache.size) {
1435
+ pageCache = pagesToKeep
1436
+ }
1437
+ } else {
1438
+ val reason = when {
1439
+ pinchActive -> "Pinch zoom active"
1440
+ recentPinch -> "Recent pinch zoom (keeping pages visible)"
1441
+ isZoomInProgress -> "Button zoom in progress"
1442
+ else -> "Unknown"
1443
+ }
1444
+ }
1445
+ } catch (e: Exception) {
1446
+ // Continue anyway
1447
+ }
1448
+
1449
+ crashContext = "ClearRenderQueue"
1450
+ try {
1451
+ // Clear render queue and stuck rendering states
1452
+ clearRenderQueue()
1453
+ } catch (e: kotlinx.coroutines.CancellationException) {
1454
+ // Check if it's a LeftCompositionCancellationException (expected when composable leaves)
1455
+ if (e.javaClass.simpleName == "LeftCompositionCancellationException") {
1456
+ // Expected when composable leaves composition - not an error
1457
+ } else {
1458
+ // Other cancellation - also expected
1459
+ }
1460
+ throw e
1461
+ } catch (e: Exception) {
1462
+ // Only log actual errors, not cancellations
1463
+ // Continue anyway - don't crash
1464
+ }
1465
+
1466
+ crashContext = "ClearRenderingPages"
1467
+ try {
1468
+ // Clear all renderingPages to prevent stuck states (ConcurrentHashMap is thread-safe)
1469
+ renderingPages.clear()
1470
+ } catch (e: Exception) {
1471
+ // Continue anyway - don't crash
1472
+ }
1473
+
1474
+ crashContext = "ClearQueuedPages"
1475
+ try {
1476
+ withContext(Dispatchers.Main) {
1477
+ queuedPages.value = emptySet()
1478
+ }
1479
+ } catch (e: kotlinx.coroutines.CancellationException) {
1480
+ // Check if it's a LeftCompositionCancellationException (expected when composable leaves)
1481
+ if (e.javaClass.simpleName == "LeftCompositionCancellationException") {
1482
+ // Expected when composable leaves composition - not an error
1483
+ } else {
1484
+ // Other cancellation - also expected
1485
+ }
1486
+ throw e
1487
+ } catch (e: Exception) {
1488
+ // Only log actual errors, not cancellations
1489
+ // Continue anyway - don't crash
1490
+ }
1491
+
1492
+
1493
+ crashContext = "WaitDelay"
1494
+ // Wait for user to finish gesture (longer for pinch zoom)
1495
+ try {
1496
+ delay(waitTime)
1497
+ } catch (e: kotlinx.coroutines.CancellationException) {
1498
+ throw e
1499
+ } catch (e: Exception) {
1500
+ isZoomInProgress = false
1501
+ return@LaunchedEffect
1502
+ }
1503
+
1504
+ crashContext = "ScaleRecheck"
1505
+ // Check if scale changed again during the delay (cancellation check)
1506
+ val updatedScale = try {
1507
+ scale
1508
+ } catch (e: Exception) {
1509
+ isZoomInProgress = false
1510
+ return@LaunchedEffect
1511
+ }
1512
+
1513
+ val updatedLastRendered = try {
1514
+ lastRenderedScale
1515
+ } catch (e: Exception) {
1516
+ isZoomInProgress = false
1517
+ return@LaunchedEffect
1518
+ }
1519
+
1520
+ crashContext = "ScaleValidationAfterWait"
1521
+ // Validate updated values
1522
+ if (updatedScale.isNaN() || updatedScale.isInfinite() || updatedScale <= 0f ||
1523
+ updatedLastRendered.isNaN() || updatedLastRendered.isInfinite() || updatedLastRendered <= 0f) {
1524
+ try {
1525
+ isZoomInProgress = false
1526
+ } catch (e: Exception) {
1527
+ }
1528
+ return@LaunchedEffect
1529
+ }
1530
+
1531
+ crashContext = "PinchZoomStillActiveCheck"
1532
+ // Check if pinch zoom is still active - if so, wait more
1533
+ if (isPinchZoomActive) {
1534
+ try {
1535
+ delay(500)
1536
+ } catch (e: kotlinx.coroutines.CancellationException) {
1537
+ throw e
1538
+ } catch (e: Exception) {
1539
+ isZoomInProgress = false
1540
+ return@LaunchedEffect
1541
+ }
1542
+
1543
+ crashContext = "FinalScaleCheck"
1544
+ // Re-check scale after additional wait
1545
+ val finalScale = try {
1546
+ scale
1547
+ } catch (e: Exception) {
1548
+ isZoomInProgress = false
1549
+ return@LaunchedEffect
1550
+ }
1551
+
1552
+ if (finalScale.isNaN() || finalScale.isInfinite() || finalScale <= 0f) {
1553
+ try {
1554
+ isZoomInProgress = false
1555
+ } catch (e: Exception) {
1556
+ }
1557
+ return@LaunchedEffect
1558
+ }
1559
+
1560
+ val scaleChange = try {
1561
+ kotlin.math.abs(finalScale - updatedScale)
1562
+ } catch (e: Exception) {
1563
+ 0f
1564
+ }
1565
+
1566
+ if (scaleChange > 0.05f) {
1567
+ // Scale changed significantly, restart
1568
+ return@LaunchedEffect
1569
+ }
1570
+ }
1571
+
1572
+ crashContext = "ScaleSettledCheck"
1573
+ // Check if scale changed again during the delay
1574
+ val finalScaleDiff = try {
1575
+ kotlin.math.abs(updatedScale - updatedLastRendered)
1576
+ } catch (e: Exception) {
1577
+ 0f
1578
+ }
1579
+
1580
+ // Reduced threshold from 0.08f to 0.03f for smoother transitions
1581
+ if (finalScaleDiff < 0.03f) {
1582
+ // Scale settled back, no need to re-render
1583
+ try {
1584
+ isZoomInProgress = false
1585
+ } catch (e: Exception) {
1586
+ }
1587
+ return@LaunchedEffect
1588
+ }
1589
+
1590
+
1591
+ crashContext = "UpdateLastRenderedScale"
1592
+ // Note: lastRenderedScale was already updated immediately when zoom started
1593
+ // This is just a confirmation/validation step
1594
+ try {
1595
+ // Double-check scale hasn't changed during wait
1596
+ val finalScale = scale
1597
+ if (kotlin.math.abs(finalScale - updatedScale) > 0.01f) {
1598
+ lastRenderedScale = finalScale
1599
+ } else {
1600
+ lastRenderedScale = updatedScale
1601
+ }
1602
+ } catch (e: Exception) {
1603
+ isZoomInProgress = false
1604
+ return@LaunchedEffect
1605
+ }
1606
+
1607
+ crashContext = "ClearLoadedBatches"
1608
+ // Clear loaded batches so pages reload at new scale
1609
+ try {
1610
+ loadedBatches = emptySet()
1611
+ } catch (e: Exception) {
1612
+ // Continue anyway
1613
+ }
1614
+
1615
+ // Don't queue ALL pages - let scroll handler load them on-demand
1616
+ // This prevents memory issues and conflicts
1617
+
1618
+ crashContext = "ResetZoomInProgress"
1619
+ // Reset zoom flag immediately so scroll handler can start loading
1620
+ try {
1621
+ isZoomInProgress = false
1622
+ } catch (e: Exception) {
1623
+ }
1624
+
1625
+ crashContext = "FinalDelay"
1626
+ // Minimal delay to let UI update - reduced for faster response and smoother zoom
1627
+ try {
1628
+ delay(30) // Reduced from 50ms to 30ms for even faster, smoother page reload
1629
+ } catch (e: kotlinx.coroutines.CancellationException) {
1630
+ throw e
1631
+ } catch (e: Exception) {
1632
+ // Continue anyway
1633
+ }
1634
+
1635
+ // Note: Scroll handler will automatically reload visible pages when lastRenderedScale changes
1636
+ // The reduced delay (30ms) ensures pages reload quickly after zoom, preventing white screen
1637
+ // Visual scaling ensures smooth transitions even before re-render completes
1638
+ }
1639
+ } catch (e: kotlinx.coroutines.CancellationException) {
1640
+ // Expected when scale changes rapidly - previous effect cancelled
1641
+ throw e
1642
+ } catch (e: OutOfMemoryError) {
1643
+ System.gc()
1644
+ // Reset zoom state on error
1645
+ try {
1646
+ isZoomInProgress = false
1647
+ } catch (ex: Exception) {
1648
+ }
1649
+ } catch (e: Exception) {
1650
+ // Reset zoom state on error
1651
+ try {
1652
+ isZoomInProgress = false
1653
+ } catch (ex: Exception) {
1654
+ }
1655
+ } catch (e: Throwable) {
1656
+ // Reset zoom state on error
1657
+ try {
1658
+ isZoomInProgress = false
1659
+ } catch (ex: Exception) {
1660
+ }
1661
+ }
1662
+ }
1663
+ }
1664
+
1665
+ // ========================================================================
1666
+ // SECTION 10: UI COMPOSITION
1667
+ // ========================================================================
1668
+
1669
+ /**
1670
+ * Helper function to load image from URL, local file, or fallback to drawable
1671
+ */
1672
+ @Composable
1673
+ fun loadIconImage(imageUrl: String?, drawableName: String, modifier: Modifier, tint: Color, contentDescription: String) {
1674
+ val context = LocalContext.current
1675
+ var bitmap by remember { mutableStateOf<android.graphics.Bitmap?>(null) }
1676
+
1677
+ LaunchedEffect(imageUrl) {
1678
+ if (!imageUrl.isNullOrEmpty()) {
1679
+ // Load from URL or local file
1680
+ bitmap = withContext(Dispatchers.IO) {
1681
+ try {
1682
+ val result = when {
1683
+ // Handle React Native resource URIs (res://drawable/... or res:/drawable/...)
1684
+ imageUrl.startsWith("res://") || imageUrl.startsWith("res:/") -> {
1685
+ try {
1686
+ // Extract resource name from URI (e.g., "res://drawable/icon_name" -> "icon_name")
1687
+ val resourceName = imageUrl.substringAfterLast("/").substringBefore(".")
1688
+ val resourceId = context.resources.getIdentifier(resourceName, "drawable", context.packageName)
1689
+ if (resourceId != 0) {
1690
+ BitmapFactory.decodeResource(context.resources, resourceId)
1691
+ } else {
1692
+ null
1693
+ }
1694
+ } catch (e: Exception) {
1695
+ null
1696
+ }
1697
+ }
1698
+ // Handle asset URIs (file:///android_asset/...)
1699
+ imageUrl.startsWith("file:///android_asset/") -> {
1700
+ val assetPath = imageUrl.removePrefix("file:///android_asset/")
1701
+ try {
1702
+ context.assets.open(assetPath).use { inputStream ->
1703
+ BitmapFactory.decodeStream(inputStream)
1704
+ }
1705
+ } catch (e: Exception) {
1706
+ null
1707
+ }
1708
+ }
1709
+ // Handle local file URIs (file://...)
1710
+ imageUrl.startsWith("file://") -> {
1711
+ val filePath = imageUrl.removePrefix("file://")
1712
+ BitmapFactory.decodeFile(filePath)
1713
+ }
1714
+ // Handle content:// URIs (content provider)
1715
+ imageUrl.startsWith("content://") -> {
1716
+ try {
1717
+ context.contentResolver.openInputStream(android.net.Uri.parse(imageUrl))?.use { inputStream ->
1718
+ BitmapFactory.decodeStream(inputStream)
1719
+ }
1720
+ } catch (e: Exception) {
1721
+ null
1722
+ }
1723
+ }
1724
+ // Handle http/https URLs
1725
+ imageUrl.startsWith("http://") || imageUrl.startsWith("https://") -> {
1726
+ try {
1727
+ val url = URL(imageUrl)
1728
+ val connection = url.openConnection()
1729
+ connection.connectTimeout = 5000
1730
+ connection.readTimeout = 5000
1731
+ connection.connect()
1732
+ val inputStream = connection.getInputStream()
1733
+ BitmapFactory.decodeStream(inputStream)
1734
+ } catch (e: Exception) {
1735
+ null
1736
+ }
1737
+ }
1738
+ // Try as numeric resource ID (React Native sometimes returns just the ID)
1739
+ imageUrl.matches(Regex("^\\d+$")) -> {
1740
+ try {
1741
+ val resourceId = imageUrl.toInt()
1742
+ BitmapFactory.decodeResource(context.resources, resourceId)
1743
+ } catch (e: Exception) {
1744
+ null
1745
+ }
1746
+ }
1747
+ // Try as asset name directly (React Native bundled assets)
1748
+ else -> {
1749
+ try {
1750
+ // First try as drawable resource
1751
+ val resourceId = context.resources.getIdentifier(imageUrl, "drawable", context.packageName)
1752
+ if (resourceId != 0) {
1753
+ BitmapFactory.decodeResource(context.resources, resourceId)
1754
+ } else {
1755
+ // Try as asset
1756
+ val assetPaths = listOf(
1757
+ imageUrl,
1758
+ "drawable-mdpi/$imageUrl",
1759
+ "drawable-hdpi/$imageUrl",
1760
+ "drawable-xhdpi/$imageUrl",
1761
+ "drawable-xxhdpi/$imageUrl"
1762
+ )
1763
+
1764
+ var loadedBitmap: android.graphics.Bitmap? = null
1765
+ assetLoop@ for (path in assetPaths) {
1766
+ try {
1767
+ val inputStream = context.assets.open(path)
1768
+ loadedBitmap = BitmapFactory.decodeStream(inputStream)
1769
+ inputStream.close()
1770
+ if (loadedBitmap != null) {
1771
+ break@assetLoop
1772
+ }
1773
+ } catch (e: Exception) {
1774
+ // Try next path
1775
+ }
1776
+ }
1777
+ loadedBitmap ?: BitmapFactory.decodeFile(imageUrl)
1778
+ }
1779
+ } catch (e: Exception) {
1780
+ // Try as file path
1781
+ BitmapFactory.decodeFile(imageUrl)
1782
+ }
1783
+ }
1784
+ }
1785
+ if (result != null) {
1786
+ } else {
1787
+ }
1788
+ result
1789
+ } catch (e: Exception) {
1790
+ null
1791
+ }
1792
+ }
1793
+ } else {
1794
+ // Fallback to drawable
1795
+ val resourceId = context.resources.getIdentifier(drawableName, "drawable", context.packageName)
1796
+ if (resourceId != 0) {
1797
+ bitmap = BitmapFactory.decodeResource(context.resources, resourceId)
1798
+ }
1799
+ }
1800
+ }
1801
+
1802
+ bitmap?.let {
1803
+ Image(
1804
+ bitmap = it.asImageBitmap(),
1805
+ contentDescription = contentDescription,
1806
+ modifier = modifier,
1807
+ colorFilter = ColorFilter.tint(tint)
1808
+ )
1809
+ } ?: run {
1810
+ // Fallback to drawable if URL loading failed
1811
+ val resourceId = context.resources.getIdentifier(drawableName, "drawable", context.packageName)
1812
+ if (resourceId != 0) {
1813
+ Image(
1814
+ painter = painterResource(id = resourceId),
1815
+ contentDescription = contentDescription,
1816
+ modifier = modifier,
1817
+ colorFilter = ColorFilter.tint(tint)
1818
+ )
1819
+ } else {
1820
+ // If drawable not found, show empty space (or a placeholder)
1821
+ Box(modifier = modifier)
1822
+ }
1823
+ }
1824
+ }
1825
+
1826
+ /**
1827
+ * Zoom In Icon - Loads from URL or drawable resource
1828
+ */
1829
+ @Composable
1830
+ fun ZoomInIcon(modifier: Modifier = Modifier, tint: Color = Color(0xFF424242), imageUrl: String? = null) {
1831
+ loadIconImage(imageUrl, "zoom_in_enable", modifier, tint, "Zoom In")
1832
+ }
1833
+
1834
+ /**
1835
+ * Zoom Out Icon - Loads from URL or drawable resource
1836
+ */
1837
+ @Composable
1838
+ fun ZoomOutIcon(modifier: Modifier = Modifier, tint: Color = Color(0xFF424242), imageUrl: String? = null) {
1839
+ loadIconImage(imageUrl, "zoom_out_enable", modifier, tint, "Zoom Out")
1840
+ }
1841
+
1842
+ /**
1843
+ * Fullscreen Icon - Loads from URL or drawable resource
1844
+ */
1845
+ @Composable
1846
+ fun FullscreenIcon(modifier: Modifier = Modifier, tint: Color = Color(0xFF424242), imageUrl: String? = null) {
1847
+ loadIconImage(imageUrl, "full_screen", modifier, tint, "Fullscreen")
1848
+ }
1849
+
1850
+ /**
1851
+ * Minimize Icon - Loads from URL or drawable resource
1852
+ */
1853
+ @Composable
1854
+ fun MinimizeIcon(modifier: Modifier = Modifier, tint: Color = Color(0xFF424242), imageUrl: String? = null) {
1855
+ loadIconImage(imageUrl, "minimize_screen", modifier, tint, "Minimize")
1856
+ }
1857
+
1858
+ /**
1859
+ * Left Layout Sidebar Icon - Loads from URL or drawable resource
1860
+ */
1861
+ @Composable
1862
+ fun LeftLayoutSidebarIcon(modifier: Modifier = Modifier, tint: Color = Color(0xFF424242), imageUrl: String? = null) {
1863
+ loadIconImage(imageUrl, "left_layout_sidebar_enable", modifier, tint, "Left Layout")
1864
+ }
1865
+
1866
+ /**
1867
+ * Right Layout Sidebar Icon - Loads from URL or drawable resource
1868
+ */
1869
+ @Composable
1870
+ fun RightLayoutSidebarIcon(modifier: Modifier = Modifier, tint: Color = Color(0xFF424242), imageUrl: String? = null) {
1871
+ loadIconImage(imageUrl, "right_layout_sidebar_enable", modifier, tint, "Right Layout")
1872
+ }
1873
+
1874
+ /**
1875
+ * Custom Reset Icon (circular arrow)
1876
+ */
1877
+ @Composable
1878
+ fun ResetIcon(modifier: Modifier = Modifier, tint: Color = Color(0x66000000)) {
1879
+ Canvas(modifier = modifier.size(24.dp)) {
1880
+ val centerX = size.width / 2
1881
+ val centerY = size.height / 2
1882
+ val radius = size.minDimension * 0.3f
1883
+
1884
+ // Draw circular arrow (reset/refresh icon)
1885
+ val path = Path()
1886
+ val startAngle = 45f
1887
+ val sweepAngle = 270f
1888
+
1889
+ // Create Rect for arc
1890
+ val oval = androidx.compose.ui.geometry.Rect(
1891
+ left = centerX - radius,
1892
+ top = centerY - radius,
1893
+ right = centerX + radius,
1894
+ bottom = centerY + radius
1895
+ )
1896
+
1897
+ // Draw arc
1898
+ path.addArc(
1899
+ oval = oval,
1900
+ startAngleDegrees = startAngle,
1901
+ sweepAngleDegrees = sweepAngle
1902
+ )
1903
+
1904
+ // Draw arrow head
1905
+ val arrowAngle = Math.toRadians((startAngle + sweepAngle).toDouble())
1906
+ val arrowX = centerX + radius * kotlin.math.cos(arrowAngle).toFloat()
1907
+ val arrowY = centerY + radius * kotlin.math.sin(arrowAngle).toFloat()
1908
+ val arrowSize = size.minDimension * 0.1f
1909
+
1910
+ // Arrow pointing right
1911
+ path.moveTo(arrowX, arrowY)
1912
+ path.lineTo(arrowX - arrowSize, arrowY - arrowSize)
1913
+ path.moveTo(arrowX, arrowY)
1914
+ path.lineTo(arrowX - arrowSize, arrowY + arrowSize)
1915
+
1916
+ drawPath(path, color = tint, style = Stroke(width = 2.dp.toPx()))
1917
+ }
1918
+ }
1919
+
1920
+ /**
1921
+ * Main UI Scaffold
1922
+ *
1923
+ * Contains:
1924
+ * - PDF page list (LazyColumn)
1925
+ * - Gesture handling (pinch zoom)
1926
+ * - Top bar with back button and text
1927
+ * - Bottom bar with zoom controls
1928
+ * - Loading screen
1929
+ */
1930
+ Scaffold(
1931
+
1932
+ topBar = {
1933
+ if (!isInitialLoading && pageCount > 0 && backButtonText != null && isFullScreenState) {
1934
+ TopAppBar(
1935
+ backgroundColor = Color.Transparent,
1936
+ elevation = 0.dp,
1937
+ contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp)
1938
+ ) {
1939
+ Row(
1940
+ modifier = Modifier.fillMaxWidth(),
1941
+ horizontalArrangement = Arrangement.Start,
1942
+ verticalAlignment = Alignment.CenterVertically
1943
+ ) {
1944
+ // Left side: Back Button (Red pill-shaped button with left arrow and "Topics" text)
1945
+ Box(
1946
+ modifier = Modifier
1947
+ .background(
1948
+ Color(0xFFF97A60.toInt()), // Red background, full opacity
1949
+ shape = RoundedCornerShape(30.dp)
1950
+ )
1951
+ .clickable(
1952
+ onClick = {
1953
+ // Exit fullscreen mode when back button is clicked
1954
+ isFullScreenState = false
1955
+ onFullScreenChange?.invoke(false)
1956
+ }
1957
+ )
1958
+ .padding(horizontal = 16.dp, vertical = 8.dp),
1959
+ contentAlignment = Alignment.Center
1960
+ ) {
1961
+ Row(
1962
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
1963
+ verticalAlignment = Alignment.CenterVertically
1964
+ ) {
1965
+ // Left Arrow Icon (gray)
1966
+ Icon(
1967
+ imageVector = Icons.Default.ArrowBack,
1968
+ contentDescription = "Back",
1969
+ tint = Color(0xFFFFFFFF),
1970
+ modifier = Modifier.size(20.dp)
1971
+ )
1972
+ // Back Button Text (e.g., "Topics")
1973
+ Text(
1974
+ text = backButtonText,
1975
+ color = Color.White,
1976
+ style = MaterialTheme.typography.body1,
1977
+ fontWeight = FontWeight.Medium
1978
+ )
1979
+ }
1980
+ }
1981
+
1982
+ // Right side: Header Text (e.g., "Book") - positioned right next to button
1983
+ if (headerText != null) {
1984
+ Text(
1985
+ text = headerText,
1986
+ color = Color.Black,
1987
+ style = MaterialTheme.typography.body1,
1988
+ fontWeight = FontWeight.Medium,
1989
+ modifier = Modifier.padding(start = 16.dp)
1990
+ )
1991
+ }
1992
+ }
1993
+ }
1994
+ }
1995
+ },
1996
+ bottomBar = {
1997
+ if (!isInitialLoading && pageCount > 0) {
1998
+ // Toolbar with gray background when not fullscreen, transparent when fullscreen
1999
+ Box(
2000
+ modifier = Modifier
2001
+ .fillMaxWidth()
2002
+ .then(
2003
+ if (!isFullScreenState) {
2004
+ Modifier.background( Color(0x66000000) )
2005
+ } else {
2006
+ Modifier // No background when fullscreen
2007
+ }
2008
+ )
2009
+ .padding(horizontal = 8.dp, vertical = 12.dp),
2010
+ contentAlignment = if (isFullScreenState) Alignment.Center else Alignment.TopStart
2011
+ ) {
2012
+ Row(
2013
+ modifier = if (isFullScreenState) {
2014
+ // Fullscreen: Center buttons with fixed spacing
2015
+ Modifier.wrapContentWidth()
2016
+ } else {
2017
+ // Split-screen: Full width with even distribution
2018
+ Modifier.fillMaxWidth()
2019
+ },
2020
+ horizontalArrangement = if (isFullScreenState) {
2021
+ // Fullscreen: Fixed 15.dp spacing between buttons
2022
+ Arrangement.spacedBy(25.dp)
2023
+ } else {
2024
+ // Split-screen: Even distribution across width
2025
+ Arrangement.SpaceEvenly
2026
+ },
2027
+ verticalAlignment = Alignment.CenterVertically
2028
+ ) {
2029
+ //* pdf positen left Icon Button */
2030
+ if(!isFullScreenState) {
2031
+ Box(
2032
+ modifier = Modifier
2033
+ .size(48.dp)
2034
+ .clickable(
2035
+ enabled = !isLeftLayoutDisabled, // Disable when PDF is on left
2036
+ onClick = {
2037
+ if (!isLeftLayoutDisabled) {
2038
+ onLeftScreenChange?.invoke(true)
2039
+ }
2040
+ }
2041
+ ),
2042
+ contentAlignment = Alignment.Center
2043
+ ) {
2044
+ LeftLayoutSidebarIcon(
2045
+ tint = if (isLeftLayoutDisabled) {
2046
+ Color(0xFF9E9E9E) // Lighter gray if disabled
2047
+ } else {
2048
+ if (isFullScreenState) Color(0xFF424242) else Color.White // Gray when fullscreen, white when not
2049
+ },
2050
+ modifier = Modifier.size(iconSize),
2051
+ imageUrl = iconUrls?.get("leftLayout")
2052
+ )
2053
+ }
2054
+
2055
+ // ** pdf positen Right Icon Button (bordered style) */
2056
+ Box(
2057
+ modifier = Modifier
2058
+ .size(48.dp)
2059
+ .clickable(
2060
+ enabled = !isRightLayoutDisabled, // Disable when PDF is on right
2061
+ onClick = {
2062
+ if (!isRightLayoutDisabled) {
2063
+ onRightScreenChange?.invoke(true)
2064
+ }
2065
+ }
2066
+ ),
2067
+ contentAlignment = Alignment.Center
2068
+ ) {
2069
+ RightLayoutSidebarIcon(
2070
+ tint = if (isRightLayoutDisabled) {
2071
+ Color(0xFF9E9E9E) // Lighter gray if disabled
2072
+ } else {
2073
+ if (isFullScreenState) Color(0xFF424242) else Color.White // Gray when fullscreen, white when not
2074
+ },
2075
+ modifier = Modifier.size(iconSize),
2076
+ imageUrl = iconUrls?.get("rightLayout")
2077
+ )
2078
+ }
2079
+ }
2080
+ // Zoom In Icon Button (bordered style)
2081
+ Box(
2082
+ modifier = Modifier
2083
+ .size(48.dp)
2084
+ .clickable(
2085
+ enabled = !isPinchZoomActive && !isZoomInDisabled, // Disable when at max zoom
2086
+ onClick = {
2087
+ if (!isPinchZoomActive && !isZoomInDisabled) {
2088
+ // Clamp to ensure we never exceed max zoom
2089
+ scale = (scale + zoomInStep).coerceAtMost(effectiveMaxZoom)
2090
+ }
2091
+ }
2092
+ ),
2093
+ contentAlignment = Alignment.Center
2094
+ ) {
2095
+ ZoomInIcon(
2096
+ tint = if (isZoomInDisabled || isPinchZoomActive) {
2097
+ Color(0xFF9E9E9E) // Lighter gray if disabled
2098
+ } else {
2099
+ if (isFullScreenState) Color(0xFF424242) else Color.White // Gray when fullscreen, white when not
2100
+ },
2101
+ modifier = Modifier.size(iconSize),
2102
+ imageUrl = iconUrls?.get("zoomIn")
2103
+ )
2104
+ }
2105
+
2106
+ // Zoom Out Icon Button (bordered style)
2107
+ Box(
2108
+ modifier = Modifier
2109
+ .size(48.dp)
2110
+ .clickable(
2111
+ enabled = !isPinchZoomActive && !isZoomOutDisabled, // Disable when at min zoom (100%)
2112
+ onClick = {
2113
+ if (!isPinchZoomActive && !isZoomOutDisabled) {
2114
+ // Clamp to ensure we never go below 100% (1.0f)
2115
+ scale = (scale - zoomOutStep).coerceAtLeast(MIN_ZOOM)
2116
+ }
2117
+ }
2118
+ ),
2119
+ contentAlignment = Alignment.Center
2120
+ ) {
2121
+ ZoomOutIcon(
2122
+ tint = if (isZoomOutDisabled || isPinchZoomActive) {
2123
+ Color(0xFF9E9E9E) // Lighter gray if disabled
2124
+ } else {
2125
+ if (isFullScreenState) Color(0xFF424242) else Color.White // Gray when fullscreen, white when not
2126
+ },
2127
+ modifier = Modifier.size(iconSize),
2128
+ imageUrl = iconUrls?.get("zoomOut")
2129
+ )
2130
+ }
2131
+
2132
+
2133
+ // Full Screen / Minimize Icon Button (bordered style)
2134
+ // Shows minimize icon when fullscreen, fullscreen icon when not
2135
+ Box(
2136
+ modifier = Modifier
2137
+ .size(48.dp)
2138
+ .clickable(
2139
+ onClick = {
2140
+ // Toggle fullscreen state and notify parent
2141
+ val newFullScreenState = !isFullScreenState
2142
+ isFullScreenState = newFullScreenState
2143
+ onFullScreenChange?.invoke(newFullScreenState)
2144
+ }
2145
+ ),
2146
+ contentAlignment = Alignment.Center
2147
+ ) {
2148
+ // Show minimize icon when fullscreen, fullscreen icon when not
2149
+ if (isFullScreenState) {
2150
+ MinimizeIcon(
2151
+ tint = Color(0xFF424242), // Gray icon when fullscreen
2152
+ modifier = Modifier.size(iconSize),
2153
+ imageUrl = iconUrls?.get("minimizeScreen")
2154
+ )
2155
+ } else {
2156
+ FullscreenIcon(
2157
+ tint = Color.White, // White icon when not fullscreen
2158
+ modifier = Modifier.size(iconSize),
2159
+ imageUrl = iconUrls?.get("fullScreen")
2160
+ )
2161
+ }
2162
+ }
2163
+
2164
+
2165
+
2166
+
2167
+ // Reset Icon Button (bordered style)
2168
+ // Box(
2169
+ // modifier = Modifier
2170
+ // .size(48.dp)
2171
+ // .background(
2172
+ // Color(0xFFF5F5F5), // Light gray background
2173
+ // shape = RoundedCornerShape(8.dp)
2174
+ // )
2175
+ // .border(
2176
+ // 1.dp,
2177
+ // Color(0xFFE0E0E0), // Light border
2178
+ // shape = RoundedCornerShape(8.dp)
2179
+ // )
2180
+ // .clickable(
2181
+ // onClick = {
2182
+ // scale = 0.3f
2183
+ // }
2184
+ // ),
2185
+ // contentAlignment = Alignment.Center
2186
+ // ) {
2187
+ // ResetIcon(
2188
+ // tint = Color(0xFF424242), // Dark gray icon
2189
+ // modifier = Modifier.size(24.dp)
2190
+ // )
2191
+ // }
2192
+ }
2193
+ }
2194
+ }
2195
+ }
2196
+ ) { paddingValues ->
2197
+ Box(
2198
+ modifier = Modifier
2199
+ .fillMaxSize()
2200
+ .then(
2201
+ if (!isFullScreenState) {
2202
+ // CRITICAL: Clip ONLY TOP corners to match border radius (10.dp)
2203
+ // This ensures the entire container (border + content) is clipped consistently
2204
+ Modifier
2205
+ .clip(RoundedCornerShape(topStart = 10.dp, topEnd = 10.dp, bottomStart = 0.dp, bottomEnd = 0.dp))
2206
+ .background(Color.Transparent)
2207
+ } else {
2208
+ Modifier
2209
+ }
2210
+ )
2211
+ .then(
2212
+ if (!isFullScreenState) {
2213
+ Modifier.padding(
2214
+ top = 0.dp,
2215
+ bottom = paddingValues.calculateBottomPadding(),
2216
+ start = paddingValues.calculateStartPadding(LayoutDirection.Ltr),
2217
+ end = paddingValues.calculateEndPadding(LayoutDirection.Ltr)
2218
+ )
2219
+ } else {
2220
+ Modifier.padding(paddingValues)
2221
+ }
2222
+ )
2223
+ .drawBehind {
2224
+ // Draw gray border with rounded corners ONLY at TOP - straight bottom
2225
+ if (!isFullScreenState) {
2226
+ val cornerRadius = 10.dp.toPx()
2227
+ val strokeWidth = 20.dp.toPx()
2228
+ val halfStroke = strokeWidth / 2f
2229
+
2230
+ val path = Path().apply {
2231
+ // Start from bottom-left (straight corner)
2232
+ moveTo(halfStroke, size.height)
2233
+
2234
+ // Left border up to top-left corner
2235
+ lineTo(halfStroke, cornerRadius + halfStroke)
2236
+
2237
+ // Top-left rounded corner
2238
+ arcTo(
2239
+ rect = androidx.compose.ui.geometry.Rect(
2240
+ left = halfStroke,
2241
+ top = halfStroke,
2242
+ right = cornerRadius * 2 + halfStroke,
2243
+ bottom = cornerRadius * 2 + halfStroke
2244
+ ),
2245
+ startAngleDegrees = 180f,
2246
+ sweepAngleDegrees = 90f,
2247
+ forceMoveTo = false
2248
+ )
2249
+
2250
+ // Top border
2251
+ lineTo(size.width - cornerRadius * 2 - halfStroke, halfStroke)
2252
+
2253
+ // Top-right rounded corner
2254
+ arcTo(
2255
+ rect = androidx.compose.ui.geometry.Rect(
2256
+ left = size.width - cornerRadius * 2 - halfStroke,
2257
+ top = halfStroke,
2258
+ right = size.width - halfStroke,
2259
+ bottom = cornerRadius * 2 + halfStroke
2260
+ ),
2261
+ startAngleDegrees = 270f,
2262
+ sweepAngleDegrees = 90f,
2263
+ forceMoveTo = false
2264
+ )
2265
+
2266
+ // Right border straight down to bottom-right (NO rounded corner)
2267
+ lineTo(size.width - halfStroke, size.height)
2268
+ }
2269
+ drawPath(
2270
+ path = path,
2271
+ color = Color(0x66000000), // Gray border
2272
+ style = Stroke(width = strokeWidth)
2273
+ )
2274
+ }
2275
+ }
2276
+ ) {
2277
+ if (!isInitialLoading && pageCount > 0) {
2278
+ Column(modifier = Modifier.fillMaxSize()) {
2279
+ // Rendering Performance Stats Panel (in debug mode or when explicitly enabled)
2280
+ // TODO: Make this toggleable via a debug flag or settings
2281
+ val showStats = remember { mutableStateOf(false) } // Set to true to enable stats display
2282
+ if (showStats.value) {
2283
+ RenderingStatsPanel(
2284
+ renderingStats = renderingStats,
2285
+ showStats = true
2286
+ )
2287
+ }
2288
+
2289
+ val lazyListState = rememberLazyListState()
2290
+ val horizontalScrollState = rememberScrollState()
2291
+ val gestureScope = rememberCoroutineScope()
2292
+
2293
+ // Get current visible page
2294
+ val currentPage = remember {
2295
+ derivedStateOf {
2296
+ lazyListState.firstVisibleItemIndex + 1
2297
+ }
2298
+ }
2299
+
2300
+ // Scroll to initial page when PDF is loaded
2301
+ LaunchedEffect(isInitialLoading, pageCount, initialPageIndex) {
2302
+ if (!isInitialLoading && pageCount > 0 && initialPageIndex >= 0 && initialPageIndex < pageCount) {
2303
+ // Wait for the list to be ready and ensure the target batch is loaded
2304
+ delay(500)
2305
+
2306
+ // Load the batch containing the target page if not already loaded
2307
+ val targetBatch = getBatchNumber(initialPageIndex)
2308
+ if (!loadedBatches.contains(targetBatch)) {
2309
+ loadBatch(targetBatch, lastRenderedScale, priority = 0)
2310
+
2311
+ // Wait for batch to load (with timeout)
2312
+ var attempts = 0
2313
+ while (attempts < 50 && !loadedBatches.contains(targetBatch)) {
2314
+ delay(100)
2315
+ attempts++
2316
+ }
2317
+ }
2318
+
2319
+ // Wait a bit more for the page to render
2320
+ delay(300)
2321
+
2322
+ try {
2323
+ // Scroll to the specified page index
2324
+ lazyListState.animateScrollToItem(
2325
+ index = initialPageIndex,
2326
+ scrollOffset = 0
2327
+ )
2328
+ } catch (e: Exception) {
2329
+ e.printStackTrace()
2330
+ }
2331
+ }
2332
+ }
2333
+
2334
+ // Retry mechanism for visible pages that failed to load
2335
+ LaunchedEffect(lazyListState.firstVisibleItemIndex) {
2336
+ delay(500) // Wait 1 second after scroll settles
2337
+
2338
+ // CRITICAL: Allow retry during recentPinchZoom to prevent white screen
2339
+ // Only skip during active zoom or active pinch zoom
2340
+ if (isZoomInProgress || isPinchZoomActive) {
2341
+ return@LaunchedEffect
2342
+ }
2343
+
2344
+ val visiblePages = lazyListState.layoutInfo.visibleItemsInfo.map { it.index }
2345
+ val targetScale = lastRenderedScale
2346
+
2347
+ // Check visible pages and retry failed ones
2348
+ visiblePages.forEach { pageIndex ->
2349
+ val cached = pageCache[pageIndex]
2350
+ val isRendering = renderingPages.containsKey(pageIndex)
2351
+ val isQueued = queuedPages.value.contains(pageIndex)
2352
+
2353
+ // If page is not cached, not rendering, and not queued, retry it
2354
+ if (cached == null && !isRendering && !isQueued) {
2355
+ launch(Dispatchers.Default) {
2356
+ try {
2357
+ val request = RenderRequest(pageIndex, targetScale, priority = 0)
2358
+ renderQueue.send(request)
2359
+ withContext(Dispatchers.Main) {
2360
+ queuedPages.value = queuedPages.value + pageIndex
2361
+ }
2362
+ } catch (e: Exception) {
2363
+ }
2364
+ }
2365
+ }
2366
+ }
2367
+ }
2368
+
2369
+ // Load pages on-demand as user scrolls (for pages beyond initial pre-load)
2370
+ LaunchedEffect(lazyListState.firstVisibleItemIndex, lastRenderedScale) {
2371
+ // CRITICAL: If lastRenderedScale just changed (zoom completed), reduce delay
2372
+ // This prevents white screen after rapid zoom, especially zoom out
2373
+ // Allow loading during recentPinchZoom to prevent white screen when scrolling
2374
+ val delayTime = if (isZoomInProgress || isPinchZoomActive) {
2375
+ 500L // Wait longer if zoom still in progress or active pinch zoom
2376
+ } else {
2377
+ 50L // Very short delay if zoom just completed - prioritize visible pages immediately
2378
+ }
2379
+
2380
+ delay(delayTime)
2381
+
2382
+ // Skip only if zoom is actively in progress (not recentPinchZoom)
2383
+ if (isZoomInProgress || isPinchZoomActive) {
2384
+ return@LaunchedEffect
2385
+ }
2386
+
2387
+ // CRITICAL FIX: Only skip if pinch zoom is actively happening (not recentPinchZoom)
2388
+ // Allow scroll handler to load pages during recentPinchZoom to prevent white screen
2389
+ // Skip only during active pinch zoom to prevent conflicts
2390
+ val timeSinceLastPinch = System.currentTimeMillis() - lastPinchZoomTime
2391
+ if (isPinchZoomActive) {
2392
+ // Only skip during active pinch zoom, not during recentPinchZoom period
2393
+ return@LaunchedEffect
2394
+ }
2395
+ // Note: We allow loading during recentPinchZoom to prevent white screen when scrolling
2396
+
2397
+ val firstVisibleIndex = lazyListState.firstVisibleItemIndex
2398
+ val currentBatch = getBatchNumber(firstVisibleIndex)
2399
+ // CRITICAL FIX: Use current scale if recentPinchZoom is active to prevent loading old pages
2400
+ // When scrolling after max/min pinch zoom, we want to load at current scale, not old lastRenderedScale
2401
+ val targetScale = if (recentPinchZoom || isPinchZoomActive) {
2402
+ // During recent pinch zoom, use current scale to ensure pages load at correct zoom (max or min)
2403
+ scale.coerceIn(MIN_ZOOM, effectiveMaxZoom)
2404
+ } else {
2405
+ // Otherwise use lastRenderedScale (for normal scrolling)
2406
+ lastRenderedScale
2407
+ }
2408
+
2409
+ // Get visible pages - prioritize loading them first
2410
+ val visiblePages = lazyListState.layoutInfo.visibleItemsInfo.map { it.index }
2411
+
2412
+ // First, queue visible pages that need rendering (at correct scale or missing)
2413
+ // CRITICAL: Use more lenient threshold for max zoom AND minimum zoom to prevent white screen
2414
+ val scaleDiffThreshold = when {
2415
+ targetScale > 1.5f -> 0.50f // For max zoom (>150%), allow up to 50% difference
2416
+ targetScale >= 1.0f && targetScale <= 1.5f -> 0.50f // CRITICAL FIX: For moderate zoom (135-150%), allow 50% difference to ensure pages reload
2417
+ targetScale <= 1.1f -> 0.50f // For minimum zoom (100% = 1.0f), also allow 50% difference (keeps pages at higher scales)
2418
+ else -> 0.08f // For normal zoom, use 8% threshold
2419
+ }
2420
+
2421
+ visiblePages.forEach { pageIndex ->
2422
+ val cached = pageCache[pageIndex]
2423
+ val needsRender = cached == null ||
2424
+ kotlin.math.abs(cached.second - targetScale) > scaleDiffThreshold
2425
+
2426
+ if (needsRender && !renderingPages.containsKey(pageIndex) && !queuedPages.value.contains(pageIndex)) {
2427
+ launch(Dispatchers.Default) {
2428
+ try {
2429
+ val request = RenderRequest(pageIndex, targetScale, priority = 0)
2430
+ renderQueue.send(request)
2431
+ withContext(Dispatchers.Main) {
2432
+ queuedPages.value = queuedPages.value + pageIndex
2433
+ }
2434
+ } catch (e: Exception) {
2435
+ }
2436
+ }
2437
+ }
2438
+ }
2439
+
2440
+ // Load current batch if not loaded (or needs re-render at new scale)
2441
+ // CRITICAL: Use same lenient threshold for max zoom
2442
+ val batchNeedsLoad = !loadedBatches.contains(currentBatch) ||
2443
+ (0 until batchSize).any { offset ->
2444
+ val pageIndex = currentBatch * batchSize + offset
2445
+ if (pageIndex >= pageCount) false
2446
+ else {
2447
+ val cached = pageCache[pageIndex]
2448
+ cached == null || kotlin.math.abs(cached.second - targetScale) > scaleDiffThreshold
2449
+ }
2450
+ }
2451
+
2452
+ if (batchNeedsLoad) {
2453
+ launch(Dispatchers.Default) {
2454
+ try {
2455
+ loadBatch(currentBatch, targetScale, priority = 1)
2456
+
2457
+ // Mark batch as loaded
2458
+ withContext(Dispatchers.Main) {
2459
+ loadedBatches = loadedBatches + currentBatch
2460
+ }
2461
+ } catch (e: Exception) {
2462
+ e.printStackTrace()
2463
+ }
2464
+ }
2465
+ }
2466
+
2467
+ // Prefetch next batch if near end of current batch (using configurable threshold)
2468
+ val positionInBatch = firstVisibleIndex % batchSize
2469
+ val nextBatch = currentBatch + 1
2470
+ if (positionInBatch >= batchSize - PdfViewerConfig.SCROLL_PREFETCH_THRESHOLD && nextBatch * batchSize < pageCount) {
2471
+ val nextBatchNeedsLoad = !loadedBatches.contains(nextBatch) ||
2472
+ ((nextBatch * batchSize) until minOf((nextBatch + 1) * batchSize, pageCount)).any { pageIndex ->
2473
+ val cached = pageCache[pageIndex]
2474
+ cached == null || kotlin.math.abs(cached.second - targetScale) > 0.08f
2475
+ }
2476
+
2477
+ if (nextBatchNeedsLoad) {
2478
+ launch(Dispatchers.Default) {
2479
+ try {
2480
+ loadBatch(nextBatch, targetScale, priority = 2)
2481
+ safeMainContext {
2482
+ loadedBatches = loadedBatches + nextBatch
2483
+ }
2484
+ } catch (e: kotlinx.coroutines.CancellationException) {
2485
+ // Check if it's a LeftCompositionCancellationException (expected when composable leaves)
2486
+ if (e.javaClass.simpleName == "LeftCompositionCancellationException") {
2487
+ // Expected when composable leaves composition - not an error
2488
+ } else {
2489
+ // Other cancellation - also expected
2490
+ }
2491
+ throw e
2492
+ } catch (e: Exception) {
2493
+ // Only log actual errors, not cancellations
2494
+ }
2495
+ }
2496
+ }
2497
+ }
2498
+
2499
+ // Prefetch previous batch if scrolling up (using configurable threshold)
2500
+ val prevBatch = currentBatch - 1
2501
+ if (positionInBatch <= PdfViewerConfig.SCROLL_PREFETCH_THRESHOLD && prevBatch >= 0) {
2502
+ val prevBatchNeedsLoad = !loadedBatches.contains(prevBatch) ||
2503
+ ((prevBatch * batchSize) until minOf((prevBatch + 1) * batchSize, pageCount)).any { pageIndex ->
2504
+ val cached = pageCache[pageIndex]
2505
+ cached == null || kotlin.math.abs(cached.second - targetScale) > 0.08f
2506
+ }
2507
+
2508
+ if (prevBatchNeedsLoad) {
2509
+ launch(Dispatchers.Default) {
2510
+ try {
2511
+ loadBatch(prevBatch, targetScale, priority = 2)
2512
+ safeMainContext {
2513
+ loadedBatches = loadedBatches + prevBatch
2514
+ }
2515
+ } catch (e: kotlinx.coroutines.CancellationException) {
2516
+ // Check if it's a LeftCompositionCancellationException (expected when composable leaves)
2517
+ if (e.javaClass.simpleName == "LeftCompositionCancellationException") {
2518
+ // Expected when composable leaves composition - not an error
2519
+ } else {
2520
+ // Other cancellation - also expected
2521
+ }
2522
+ throw e
2523
+ } catch (e: Exception) {
2524
+ // Only log actual errors, not cancellations
2525
+ }
2526
+ }
2527
+ }
2528
+ }
2529
+
2530
+ // Manage cache size - keep only nearby batches
2531
+ // CRITICAL: Skip cache management during zoom OR recentPinchZoom to prevent clearing visible pages
2532
+ // This prevents white screen when scrolling after max pinch zoom
2533
+ if (!isZoomInProgress && !isPinchZoomActive && !recentPinchZoom) {
2534
+ try {
2535
+ manageCacheSize(firstVisibleIndex)
2536
+ } catch (e: kotlinx.coroutines.CancellationException) {
2537
+ // Check if it's a LeftCompositionCancellationException (expected when composable leaves)
2538
+ if (e.javaClass.simpleName == "LeftCompositionCancellationException") {
2539
+ // Expected when composable leaves composition - not an error
2540
+ } else {
2541
+ // Other cancellation - also expected
2542
+ }
2543
+ throw e
2544
+ } catch (e: Exception) {
2545
+ // Only log actual errors, not cancellations
2546
+ e.printStackTrace()
2547
+ }
2548
+ } else {
2549
+ val reason = when {
2550
+ isZoomInProgress -> "zoom in progress"
2551
+ isPinchZoomActive -> "pinch zoom active"
2552
+ recentPinchZoom -> "recent pinch zoom (keeping pages at max zoom)"
2553
+ else -> "unknown"
2554
+ }
2555
+ }
2556
+ }
2557
+
2558
+ // Force reload visible pages when cache cleanup requested it (e.g., after aggressive cleanup at max zoom)
2559
+ LaunchedEffect(reloadVisiblePagesToken) {
2560
+ if (reloadVisiblePagesToken == 0) {
2561
+ return@LaunchedEffect
2562
+ }
2563
+
2564
+
2565
+ // Wait briefly if zoom or pinch is still active to avoid racing with gestures
2566
+ var attempts = 0
2567
+ while ((isZoomInProgress || isPinchZoomActive) && attempts < 20) {
2568
+ delay(100)
2569
+ attempts++
2570
+ }
2571
+
2572
+ val visiblePages = lazyListState.layoutInfo.visibleItemsInfo.map { it.index }
2573
+ if (visiblePages.isEmpty()) {
2574
+ return@LaunchedEffect
2575
+ }
2576
+
2577
+ val targetScale = if (recentPinchZoom || isPinchZoomActive) {
2578
+ scale.coerceIn(MIN_ZOOM, effectiveMaxZoom)
2579
+ } else {
2580
+ lastRenderedScale
2581
+ }
2582
+
2583
+ val scaleDiffThreshold = if (targetScale > 1.5f || (targetScale >= 1.0f && targetScale <= 1.5f) || targetScale <= 0.35f) {
2584
+ 0.50f // CRITICAL FIX: Include moderate zoom (135-150%) in lenient threshold
2585
+ } else {
2586
+ 0.08f
2587
+ }
2588
+
2589
+ visiblePages.forEach { pageIndex ->
2590
+ val cached = pageCache[pageIndex]
2591
+ val needsRender = cached == null ||
2592
+ kotlin.math.abs(cached.second - targetScale) > scaleDiffThreshold
2593
+
2594
+ if (needsRender && !renderingPages.containsKey(pageIndex) && !queuedPages.value.contains(pageIndex)) {
2595
+ launch(Dispatchers.Default) {
2596
+ try {
2597
+ val request = RenderRequest(pageIndex, targetScale, priority = 0)
2598
+ renderQueue.send(request)
2599
+ withContext(Dispatchers.Main) {
2600
+ queuedPages.value = queuedPages.value + pageIndex
2601
+ }
2602
+ } catch (e: Exception) {
2603
+ }
2604
+ }
2605
+ }
2606
+ }
2607
+ }
2608
+
2609
+ // LaunchedEffect to detect when pinch zoom gesture ends (no updates for 500ms)
2610
+ // Use a single job that gets cancelled and restarted
2611
+ LaunchedEffect(lastPinchZoomTime) {
2612
+ try {
2613
+ delay(500)
2614
+ val currentTime = System.currentTimeMillis()
2615
+ val timeSinceLastUpdate = currentTime - lastPinchZoomTime
2616
+ if (timeSinceLastUpdate >= 500 && isPinchZoomActive) {
2617
+ isPinchZoomActive = false
2618
+ // CRITICAL FIX: Update lastRenderedScale immediately when pinch zoom ends
2619
+ // This ensures scroll handler loads pages at the correct (max) zoom scale
2620
+ try {
2621
+ lastRenderedScale = scale.coerceIn(MIN_ZOOM, effectiveMaxZoom)
2622
+ } catch (e: Exception) {
2623
+ }
2624
+
2625
+ // Set recentPinchZoom to true to keep pages visible while re-rendering
2626
+ recentPinchZoom = true
2627
+ Log.d("PdfRendererTag", "pinch zoom is deactivated")
2628
+
2629
+ // Keep recentPinchZoom true for 3 seconds to allow re-rendering to complete
2630
+ delay(3000)
2631
+ recentPinchZoom = false
2632
+ }
2633
+ } catch (e: kotlinx.coroutines.CancellationException) {
2634
+ // Expected when new gesture starts
2635
+ throw e
2636
+ } catch (e: Exception) {
2637
+ }
2638
+ }
2639
+
2640
+ Box(
2641
+ modifier = Modifier
2642
+ .fillMaxSize()
2643
+ .then(
2644
+ if (!isFullScreenState) {
2645
+ // CRITICAL: Inset content by the full border stroke width
2646
+ // Border stroke is 20.dp wide, centered on path at 10.dp from edge
2647
+ // Inner edge of border = path (10.dp) + halfStroke (10.dp) = 20.dp
2648
+ // This ensures PDF content stays fully inside the gray border
2649
+ Modifier.padding(start = 20.dp, end = 20.dp, top = 20.dp, bottom = 0.dp)
2650
+ } else {
2651
+ Modifier // No padding in fullscreen
2652
+ }
2653
+ )
2654
+ // FULLY MANUAL GESTURE HANDLER: All scrolling is handled manually for consistency
2655
+ // - 1 finger: Manual scroll (both vertical and horizontal)
2656
+ // - 2 fingers: Pinch zoom ONLY (scroll disabled)
2657
+ // - 3+ fingers: Manual scroll (both vertical and horizontal)
2658
+ // - NO system scroll - LazyColumn has userScrollEnabled = false
2659
+ .pointerInput(lazyListState, horizontalScrollState) {
2660
+ // Track gesture state
2661
+ var isScrolling = false
2662
+ var lastScrollY = 0f
2663
+ var lastScrollX = 0f
2664
+ var scrollDirection: String? = null
2665
+ var refPointerId: Long = -1L
2666
+ var scrollMode = "NONE"
2667
+
2668
+ val directionLockThreshold = 15f
2669
+
2670
+ val flingDecay = exponentialDecay<Float>(frictionMultiplier = 1.2f)
2671
+ val velocityTracker = VelocityTracker()
2672
+ var flingJob: Job? = null
2673
+ var stopFling = false
2674
+
2675
+ var prevHorizontalDistance = -1f
2676
+ var twoFingerFrameCount = 0
2677
+
2678
+ val minStablePinchFrames = 3
2679
+ val horizontalZoomThreshold = 2f
2680
+ val horizontalDominanceRatio = 1.2f
2681
+ val maxZoomPerFrame = 1.05f
2682
+ val fullDistanceZoomThreshold = 10f
2683
+
2684
+ awaitPointerEventScope {
2685
+ while (true) {
2686
+ val event = awaitPointerEvent(PointerEventPass.Initial)
2687
+ val pointers = event.changes.filter { it.pressed }
2688
+ val pointerCount = pointers.size
2689
+
2690
+ event.changes.forEach { it.consume() }
2691
+
2692
+ when {
2693
+ // 1 finger OR 3+ fingers: AXIS-LOCKED SCROLL
2694
+ // Uses a single reference pointer (pointers[0] or
2695
+ // the previously tracked pointer), NOT centroid.
2696
+ pointerCount == 1 || pointerCount >= 3 -> {
2697
+ prevHorizontalDistance = -1f
2698
+ twoFingerFrameCount = 0
2699
+
2700
+ scrollMode = if (pointerCount == 1) "SINGLE_SCROLL" else "MULTI_SCROLL"
2701
+
2702
+ val refPointer = pointers.firstOrNull {
2703
+ it.id.value == refPointerId
2704
+ } ?: pointers[0]
2705
+ val refX = refPointer.position.x
2706
+ val refY = refPointer.position.y
2707
+ val refIdChanged = refPointer.id.value != refPointerId
2708
+
2709
+ val needsReAnchor = refIdChanged || !isScrolling
2710
+
2711
+ velocityTracker.addPosition(
2712
+ refPointer.uptimeMillis,
2713
+ Offset(refX, refY)
2714
+ )
2715
+
2716
+ if (needsReAnchor) {
2717
+ if (!isScrolling) {
2718
+ val hadActiveFling = flingJob?.isActive == true
2719
+ stopFling = true
2720
+ flingJob?.cancel()
2721
+ flingJob = null
2722
+ velocityTracker.resetTracking()
2723
+ velocityTracker.addPosition(
2724
+ refPointer.uptimeMillis,
2725
+ Offset(refX, refY)
2726
+ )
2727
+ if (!hadActiveFling) {
2728
+ scrollDirection = null
2729
+ }
2730
+ Log.d("PdfGesture",
2731
+ "GESTURE_START hadFling=$hadActiveFling " +
2732
+ "preservedDir=$scrollDirection"
2733
+ )
2734
+ }
2735
+ isScrolling = true
2736
+ lastScrollX = refX
2737
+ lastScrollY = refY
2738
+ refPointerId = refPointer.id.value
2739
+ Log.d("PdfGesture",
2740
+ "mode=$scrollMode " +
2741
+ "${if (refIdChanged) "RE-ANCHOR" else "START"} " +
2742
+ "refPtr=$refPointerId " +
2743
+ "X=${"%.1f".format(refX)} Y=${"%.1f".format(refY)} " +
2744
+ "dir=$scrollDirection"
2745
+ )
2746
+ } else {
2747
+ val rawDeltaX = lastScrollX - refX
2748
+ val rawDeltaY = lastScrollY - refY
2749
+
2750
+ if (scrollDirection == null) {
2751
+ val absX = kotlin.math.abs(rawDeltaX)
2752
+ val absY = kotlin.math.abs(rawDeltaY)
2753
+
2754
+ if (absX > directionLockThreshold || absY > directionLockThreshold) {
2755
+ scrollDirection = if (absX > absY) "horizontal" else "vertical"
2756
+ Log.d("PdfGesture",
2757
+ "mode=$scrollMode axis=$scrollDirection " +
2758
+ "refPtr=$refPointerId"
2759
+ )
2760
+ }
2761
+ }
2762
+
2763
+ when (scrollDirection) {
2764
+ "vertical" -> {
2765
+ val deltaY = rawDeltaY
2766
+ if (kotlin.math.abs(deltaY) > 0.1f) {
2767
+ lazyListState.dispatchRawDelta(deltaY)
2768
+ }
2769
+ lastScrollY = refY
2770
+ }
2771
+ "horizontal" -> {
2772
+ val deltaX = rawDeltaX
2773
+ if (kotlin.math.abs(deltaX) > 0.1f) {
2774
+ horizontalScrollState.dispatchRawDelta(deltaX)
2775
+ }
2776
+ lastScrollX = refX
2777
+ }
2778
+ null -> {
2779
+ lastScrollX = refX
2780
+ lastScrollY = refY
2781
+ }
2782
+ }
2783
+ }
2784
+ }
2785
+
2786
+ // 2 fingers: HORIZONTAL-ONLY PINCH ZOOM
2787
+ // During settling, scroll state is preserved so that brief
2788
+ // 3→2→3 pointer-count jitter doesn't destroy momentum.
2789
+ pointerCount == 2 -> {
2790
+ twoFingerFrameCount++
2791
+
2792
+ // ── Settling + was scrolling → keep scrolling ─────
2793
+ if (twoFingerFrameCount <= minStablePinchFrames && isScrolling) {
2794
+ val refPointer = pointers.firstOrNull {
2795
+ it.id.value == refPointerId
2796
+ } ?: pointers[0]
2797
+ val refX = refPointer.position.x
2798
+ val refY = refPointer.position.y
2799
+ val refIdChanged = refPointer.id.value != refPointerId
2800
+
2801
+ velocityTracker.addPosition(
2802
+ refPointer.uptimeMillis,
2803
+ Offset(refX, refY)
2804
+ )
2805
+
2806
+ if (refIdChanged) {
2807
+ lastScrollX = refX
2808
+ lastScrollY = refY
2809
+ refPointerId = refPointer.id.value
2810
+ } else {
2811
+ val rawDeltaX = lastScrollX - refX
2812
+ val rawDeltaY = lastScrollY - refY
2813
+ when (scrollDirection) {
2814
+ "vertical" -> {
2815
+ if (kotlin.math.abs(rawDeltaY) > 0.1f) {
2816
+ lazyListState.dispatchRawDelta(rawDeltaY)
2817
+ }
2818
+ lastScrollY = refY
2819
+ }
2820
+ "horizontal" -> {
2821
+ if (kotlin.math.abs(rawDeltaX) > 0.1f) {
2822
+ horizontalScrollState.dispatchRawDelta(rawDeltaX)
2823
+ }
2824
+ lastScrollX = refX
2825
+ }
2826
+ null -> {
2827
+ lastScrollX = refX
2828
+ lastScrollY = refY
2829
+ }
2830
+ }
2831
+ }
2832
+
2833
+ prevHorizontalDistance = kotlin.math.abs(
2834
+ pointers[1].position.x - pointers[0].position.x
2835
+ )
2836
+ Log.d("PdfGesture",
2837
+ "2-finger SETTLING (scroll preserved) " +
2838
+ "frame=$twoFingerFrameCount/$minStablePinchFrames " +
2839
+ "mode=$scrollMode refPtr=$refPointerId"
2840
+ )
2841
+
2842
+ // ── Settling complete or wasn't scrolling → zoom path ─
2843
+ } else {
2844
+ isScrolling = false
2845
+ scrollDirection = null
2846
+ refPointerId = -1L
2847
+ scrollMode = "PINCH_ZOOM"
2848
+ velocityTracker.resetTracking()
2849
+
2850
+ val pointer1 = pointers[0]
2851
+ val pointer2 = pointers[1]
2852
+
2853
+ val pos1 = pointer1.position
2854
+ val pos2 = pointer2.position
2855
+ val prevPos1 = pointer1.previousPosition
2856
+ val prevPos2 = pointer2.previousPosition
2857
+
2858
+ val currentDx = pos2.x - pos1.x
2859
+ val currentDy = pos2.y - pos1.y
2860
+ val currentDistance = kotlin.math.sqrt(currentDx * currentDx + currentDy * currentDy)
2861
+
2862
+ val prevDx = prevPos2.x - prevPos1.x
2863
+ val prevDy = prevPos2.y - prevPos1.y
2864
+ val previousDistance = kotlin.math.sqrt(prevDx * prevDx + prevDy * prevDy)
2865
+
2866
+ val currentHorizDist = kotlin.math.abs(pos2.x - pos1.x)
2867
+ val currentVertDist = kotlin.math.abs(pos2.y - pos1.y)
2868
+
2869
+ val horizDelta =
2870
+ if (prevHorizontalDistance >= 0f)
2871
+ kotlin.math.abs(currentHorizDist - prevHorizontalDistance)
2872
+ else 0f
2873
+
2874
+ Log.d("PdfGesture",
2875
+ "PinchZoom | pointers=2 | " +
2876
+ "frame=$twoFingerFrameCount | " +
2877
+ "fullDist=${currentDistance.toInt()} | " +
2878
+ "horizDist=${currentHorizDist.toInt()} | " +
2879
+ "vertDist=${currentVertDist.toInt()} | " +
2880
+ "horizDelta=${"%.1f".format(horizDelta)} | " +
2881
+ "prevHorizDist=${prevHorizontalDistance.toInt()}"
2882
+ )
2883
+
2884
+ if (prevHorizontalDistance < 0f) {
2885
+ Log.d("PdfGesture", "PinchZoom SKIP — setting horizontal reference (safety)")
2886
+ prevHorizontalDistance = currentHorizDist
2887
+
2888
+ } else {
2889
+ val fullDistanceDelta = if (previousDistance > 0f) kotlin.math.abs(currentDistance - previousDistance) else 0f
2890
+ val fullDistanceOverride = fullDistanceDelta >= fullDistanceZoomThreshold
2891
+
2892
+ val isHorizDominant = currentHorizDist >= currentVertDist * horizontalDominanceRatio
2893
+
2894
+ val hasMeaningfulHorizChange = horizDelta >= horizontalZoomThreshold
2895
+
2896
+ if (!isHorizDominant && !fullDistanceOverride) {
2897
+ Log.d("PdfGesture",
2898
+ "PinchZoom BLOCKED — vertical dominant " +
2899
+ "(horizDist=${currentHorizDist.toInt()} " +
2900
+ "vertDist=${currentVertDist.toInt()})"
2901
+ )
2902
+ } else if (!fullDistanceOverride && !hasMeaningfulHorizChange) {
2903
+ Log.d("PdfGesture",
2904
+ "PinchZoom BLOCKED — horizDelta ${"%.1f".format(horizDelta)} < $horizontalZoomThreshold px"
2905
+ )
2906
+ } else if (previousDistance > 0f && currentDistance > 0f) {
2907
+ val rawZoom = currentDistance / previousDistance
2908
+ val zoom = rawZoom.coerceIn(1f / maxZoomPerFrame, maxZoomPerFrame)
2909
+
2910
+ Log.d("PdfGesture",
2911
+ "PinchZoom ALLOWED | " +
2912
+ "rawZoom=${"%.4f".format(rawZoom)} | " +
2913
+ "zoom=${"%.4f".format(zoom)} | " +
2914
+ "horizDelta=${"%.1f".format(horizDelta)} | " +
2915
+ "fullDelta=${"%.1f".format(fullDistanceDelta)}" +
2916
+ if (fullDistanceOverride) " [full-dist override]" else ""
2917
+ )
2918
+
2919
+ touchGestureCount++
2920
+
2921
+ try {
2922
+ if (!zoom.isNaN() && !zoom.isInfinite() && zoom > 0f) {
2923
+ if (kotlin.math.abs(zoom - 1.0f) >= 0.005f) {
2924
+ val currentTime = System.currentTimeMillis()
2925
+ isPinchZoomActive = true
2926
+ lastPinchZoomTime = currentTime
2927
+
2928
+ val timeSinceLastUpdate = currentTime - lastScaleUpdateTime
2929
+ val throttleTime = 16L
2930
+
2931
+ if (timeSinceLastUpdate >= throttleTime) {
2932
+ val currentScale = scale
2933
+ if (!currentScale.isNaN() && !currentScale.isInfinite() && currentScale > 0f) {
2934
+ val newScale = (currentScale * zoom).coerceIn(MIN_ZOOM, effectiveMaxZoom)
2935
+ if (!newScale.isNaN() && !newScale.isInfinite()) {
2936
+ if (kotlin.math.abs(currentScale - newScale) > 0.005f) {
2937
+ scale = newScale
2938
+ lastScaleUpdateTime = currentTime
2939
+ Log.d("PdfGesture",
2940
+ "Scale updated: ${"%.3f".format(newScale)} " +
2941
+ "(${(newScale * 100).toInt()}%)"
2942
+ )
2943
+ }
2944
+ }
2945
+ } else {
2946
+ scale = MIN_ZOOM
2947
+ lastScaleUpdateTime = currentTime
2948
+ }
2949
+ }
2950
+ }
2951
+ }
2952
+ } catch (e: OutOfMemoryError) {
2953
+ System.gc()
2954
+ } catch (e: Exception) {
2955
+ // skip update on error
2956
+ }
2957
+ }
2958
+
2959
+ prevHorizontalDistance = currentHorizDist
2960
+ }
2961
+ }
2962
+ }
2963
+
2964
+ // 0 fingers: all pointers lifted — launch fling if scrolling
2965
+ else -> {
2966
+ prevHorizontalDistance = -1f
2967
+ twoFingerFrameCount = 0
2968
+ refPointerId = -1L
2969
+
2970
+ val launchedFling = isScrolling && scrollDirection != null
2971
+ if (launchedFling) {
2972
+ val velocity = velocityTracker.calculateVelocity()
2973
+ val capturedDirection = scrollDirection!!
2974
+ Log.d("PdfGesture",
2975
+ "FLING_LAUNCH dir=$capturedDirection " +
2976
+ "vx=${"%.0f".format(velocity.x)} " +
2977
+ "vy=${"%.0f".format(velocity.y)}"
2978
+ )
2979
+ flingJob?.cancel()
2980
+ stopFling = false
2981
+ flingJob = gestureScope.launch {
2982
+ try {
2983
+ when (capturedDirection) {
2984
+ "vertical" -> {
2985
+ val initialVelocity = -velocity.y
2986
+ if (kotlin.math.abs(initialVelocity) > 50f) {
2987
+ lazyListState.scroll {
2988
+ val scrollScope = this
2989
+ var lastValue = 0f
2990
+ AnimationState(
2991
+ initialValue = 0f,
2992
+ initialVelocity = initialVelocity
2993
+ ).animateDecay(flingDecay) {
2994
+ if (stopFling) {
2995
+ Log.d("PdfGesture", "FLING_KILLED by flag")
2996
+ cancelAnimation()
2997
+ return@animateDecay
2998
+ }
2999
+ val delta = value - lastValue
3000
+ lastValue = value
3001
+ val consumed = scrollScope.scrollBy(delta)
3002
+ if (kotlin.math.abs(consumed) < kotlin.math.abs(delta) * 0.5f) {
3003
+ Log.d("PdfGesture", "FLING_END boundary hit")
3004
+ cancelAnimation()
3005
+ }
3006
+ }
3007
+ }
3008
+ } else {
3009
+ Log.d("PdfGesture", "FLING_SKIP velocity too low: ${"%.0f".format(initialVelocity)}")
3010
+ }
3011
+ }
3012
+ "horizontal" -> {
3013
+ val initialVelocity = -velocity.x
3014
+ if (kotlin.math.abs(initialVelocity) > 50f) {
3015
+ horizontalScrollState.scroll {
3016
+ val scrollScope = this
3017
+ var lastValue = 0f
3018
+ AnimationState(
3019
+ initialValue = 0f,
3020
+ initialVelocity = initialVelocity
3021
+ ).animateDecay(flingDecay) {
3022
+ if (stopFling) {
3023
+ Log.d("PdfGesture", "FLING_KILLED by flag")
3024
+ cancelAnimation()
3025
+ return@animateDecay
3026
+ }
3027
+ val delta = value - lastValue
3028
+ lastValue = value
3029
+ scrollScope.scrollBy(delta)
3030
+ }
3031
+ }
3032
+ } else {
3033
+ Log.d("PdfGesture", "FLING_SKIP velocity too low: ${"%.0f".format(initialVelocity)}")
3034
+ }
3035
+ }
3036
+ }
3037
+ Log.d("PdfGesture", "FLING_COMPLETE dir=$capturedDirection")
3038
+ } catch (e: Exception) {
3039
+ if (e !is kotlinx.coroutines.CancellationException) {
3040
+ Log.e("PdfGesture", "Fling error: ${e.message}")
3041
+ } else {
3042
+ Log.d("PdfGesture", "FLING_CANCELLED dir=$capturedDirection")
3043
+ }
3044
+ }
3045
+ }
3046
+ }
3047
+
3048
+ isScrolling = false
3049
+ lastScrollX = 0f
3050
+ lastScrollY = 0f
3051
+ if (!launchedFling) scrollDirection = null
3052
+ scrollMode = "NONE"
3053
+ velocityTracker.resetTracking()
3054
+
3055
+ Log.d("PdfGesture",
3056
+ "ALL_UP launchedFling=$launchedFling " +
3057
+ "preservedDir=$scrollDirection"
3058
+ )
3059
+ }
3060
+ }
3061
+ }
3062
+ }
3063
+ }
3064
+ ) {
3065
+ LazyColumn(
3066
+ state = lazyListState,
3067
+ modifier = Modifier
3068
+ .fillMaxSize()
3069
+ .horizontalScroll(horizontalScrollState, enabled = false) // Disabled - fully manual control
3070
+ .padding(start = 0.dp, end = 0.dp, bottom = 16.dp, top = 0.dp), // No side padding (parent Box handles border inset)
3071
+ horizontalAlignment = Alignment.CenterHorizontally,
3072
+ verticalArrangement = Arrangement.spacedBy(24.dp),
3073
+ // DISABLE system scroll - all scrolling (both vertical and horizontal) is handled manually in pointerInput
3074
+ userScrollEnabled = false
3075
+ ) {
3076
+ items(pageCount) { index ->
3077
+ val cachedPage = pageCache[index]
3078
+ val bitmap = cachedPage?.first
3079
+ val bitmapScale = cachedPage?.second ?: scale
3080
+
3081
+ Column(
3082
+ horizontalAlignment = Alignment.CenterHorizontally,
3083
+ modifier = Modifier.wrapContentSize()
3084
+ ) {
3085
+ // Check if scale difference is significant (for zoom re-rendering)
3086
+ val scaleDifference = kotlin.math.abs(scale - bitmapScale)
3087
+ // Reduced threshold from 0.08f to 0.05f (5%) for more responsive re-rendering
3088
+ val needsRerender = scaleDifference > 0.05f
3089
+
3090
+ // CRITICAL: Validate bitmap is not null and not recycled RIGHT BEFORE USE
3091
+ // This prevents race condition where bitmap is recycled between check and use
3092
+ val isValidBitmap = try {
3093
+ bitmap != null && !bitmap.isRecycled && bitmap.width > 0 && bitmap.height > 0
3094
+ } catch (e: Exception) {
3095
+ false
3096
+ }
3097
+
3098
+ // FIX: Show pages with visual scaling for smooth zoom experience
3099
+ // CRITICAL: During pinch zoom, always allow visual scaling (no hiding pages)
3100
+ // For zoom out: be more lenient - allow showing larger pages (can scale down)
3101
+ // For zoom in: only show pages within reasonable range
3102
+ val isZoomingOut = bitmapScale > scale
3103
+ val maxVisualScaleDiff = if (isZoomingOut) {
3104
+ // CRITICAL FIX: For minimum zoom, allow much larger visual scaling
3105
+ // This prevents white screen when zooming out to minimum
3106
+ if (scale <= 1.1f) {
3107
+ 10.0f // Allow up to 10x visual scaling for minimum zoom (e.g., 2.5f -> 1.0f)
3108
+ } else {
3109
+ 0.50f // For normal zoom out, allow up to 50% difference
3110
+ }
3111
+ } else {
3112
+ // CRITICAL FIX: For zoom in, increase threshold to handle 100% -> 150% (50% difference)
3113
+ // Also handle moderate zoom levels (135-150%) properly
3114
+ if (scale >= 1.0f && scale <= 1.5f) {
3115
+ 0.60f // Allow up to 60% difference for moderate zoom (handles 100% -> 150%)
3116
+ } else {
3117
+ 0.40f // Stricter for other zoom in - 40% difference
3118
+ }
3119
+ }
3120
+ // CRITICAL FIX: During pinch zoom AND recent pinch zoom, ALWAYS show pages regardless of scale difference
3121
+ // This prevents white screen during pinch zoom, especially at max zoom
3122
+ // recentPinchZoom keeps pages visible for 3 seconds after pinch ends to allow re-rendering
3123
+ // For button clicks, also allow visual scaling for smooth experience
3124
+ val isScaleAcceptable = if (isPinchZoomActive || recentPinchZoom) {
3125
+ true // Always show during pinch zoom and recent pinch zoom - visual scaling handles it, even at max zoom
3126
+ } else {
3127
+ // For button clicks, also allow visual scaling for smooth experience
3128
+ // Only hide if scale difference is too large AND we're not actively zooming
3129
+ scaleDifference < maxVisualScaleDiff || isZoomInProgress
3130
+ }
3131
+
3132
+ // CRITICAL: During pinch zoom or recent pinch zoom, always show something (bitmap or placeholder)
3133
+ // This prevents white screen at max zoom
3134
+ // CRITICAL FIX: During pinch zoom, ALWAYS show bitmap if it exists, even if being re-rendered
3135
+ // This prevents white screen on the page being zoomed
3136
+ val shouldShowBitmap = if (isPinchZoomActive || recentPinchZoom) {
3137
+ // During pinch zoom, show bitmap if it exists (even if scale doesn't match perfectly)
3138
+ isValidBitmap
3139
+ } else {
3140
+ // Normal case: show bitmap if valid and scale is acceptable
3141
+ isValidBitmap && (isScaleAcceptable || isZoomInProgress)
3142
+ }
3143
+ val shouldShowPlaceholder = (isPinchZoomActive || recentPinchZoom) && !isValidBitmap
3144
+
3145
+ if (shouldShowBitmap || shouldShowPlaceholder) {
3146
+ // Page is loaded - show it with visual scaling if needed
3147
+ // OR show placeholder during pinch zoom if bitmap not available
3148
+ // CRITICAL: Re-check bitmap validity right before using it
3149
+ val safeBitmap = try {
3150
+ if (bitmap != null && !bitmap.isRecycled && bitmap.width > 0 && bitmap.height > 0) {
3151
+ bitmap
3152
+ } else {
3153
+ null
3154
+ }
3155
+ } catch (e: Exception) {
3156
+ null
3157
+ }
3158
+
3159
+ if (safeBitmap == null) {
3160
+ // Bitmap was recycled or not available - show placeholder
3161
+ // During pinch zoom, always show placeholder to prevent white screen
3162
+ Box(
3163
+ modifier = Modifier
3164
+ .width((screenWidthDp - 32.dp) * scale)
3165
+ .height((screenWidthDp - 32.dp) * scale * 1.414f), // A4 aspect ratio
3166
+ contentAlignment = Alignment.Center
3167
+ ) {
3168
+ CircularProgressIndicator()
3169
+ }
3170
+ } else {
3171
+ // CRITICAL: Final validation right before asImageBitmap()
3172
+ val finalBitmap = try {
3173
+ if (!safeBitmap.isRecycled && safeBitmap.width > 0 && safeBitmap.height > 0) {
3174
+ safeBitmap
3175
+ } else {
3176
+ null
3177
+ }
3178
+ } catch (e: Exception) {
3179
+ null
3180
+ }
3181
+
3182
+ if (finalBitmap == null) {
3183
+ // Show placeholder if bitmap became invalid
3184
+ Box(
3185
+ modifier = Modifier
3186
+ .width((screenWidthDp - 32.dp) * scale)
3187
+ .height((screenWidthDp - 32.dp) * scale * 1.414f),
3188
+ contentAlignment = Alignment.Center
3189
+ ) {
3190
+ CircularProgressIndicator()
3191
+ }
3192
+ } else {
3193
+ val aspectRatio = finalBitmap.width.toFloat() / finalBitmap.height.toFloat()
3194
+
3195
+ // FIX: Prevent double-scaling issue
3196
+ // Problem: When zooming in, displayWidth was set to new scale AND graphicsLayer scaled again
3197
+ // Solution: Calculate displayWidth based on bitmapScale, then apply visual scaling factor
3198
+
3199
+ // Base display width should match the bitmap's actual rendered scale
3200
+ val baseDisplayWidth = (screenWidthDp - 32.dp) * bitmapScale
3201
+
3202
+ // Calculate visual scale factor to match current scale
3203
+ // CRITICAL: During pinch zoom AND recent pinch zoom, allow MUCH larger visual scaling
3204
+ // This prevents white screen when zooming to max zoom (e.g., from 1.0f to 2.5f)
3205
+ // Apply visual scaling for all differences during pinch zoom and recent pinch zoom
3206
+ val effectiveMaxDiff = if (isPinchZoomActive || recentPinchZoom) {
3207
+ // During pinch zoom and recent pinch zoom, allow ANY scale difference - no limit
3208
+ // This ensures pages are always shown during pinch zoom, even at max zoom
3209
+ Float.MAX_VALUE // No limit during pinch zoom and recent pinch zoom
3210
+ } else if (isZoomInProgress) {
3211
+ // During button zoom, use the same threshold as maxVisualScaleDiff for consistency
3212
+ // This ensures pages stay visible during zoom transitions at moderate zoom levels
3213
+ maxVisualScaleDiff // Use dynamic threshold based on zoom level
3214
+ } else {
3215
+ maxVisualScaleDiff
3216
+ }
3217
+
3218
+ val visualScaleFactor = if (isPinchZoomActive || recentPinchZoom) {
3219
+ // During pinch zoom and recent pinch zoom, always apply visual scaling regardless of difference
3220
+ // This prevents white screen at max zoom AND minimum zoom
3221
+ // Cap visual scaling factor to prevent extreme distortion (max 10x scaling)
3222
+ val rawFactor = scale / bitmapScale
3223
+ // CRITICAL FIX: For zoom out to minimum, allow larger visual scaling
3224
+ val maxVisualScale = if (scale <= 1.1f && bitmapScale > scale) {
3225
+ 10f // Allow up to 10x for zoom out to minimum (e.g., 2.5f -> 1.0f)
3226
+ } else {
3227
+ 10f // Standard cap at 10x
3228
+ }
3229
+ kotlin.math.min(rawFactor, maxVisualScale) // Cap to prevent rendering issues
3230
+ } else if (scaleDifference > 0.05f && scaleDifference < effectiveMaxDiff) {
3231
+ scale / bitmapScale // Scale factor to match current scale
3232
+ } else if (scaleDifference >= effectiveMaxDiff) {
3233
+ // For very large differences during button zoom, cap visual scaling
3234
+ val maxAllowedScale = bitmapScale + maxVisualScaleDiff
3235
+ kotlin.math.min(scale, maxAllowedScale) / bitmapScale
3236
+ } else {
3237
+ // No scaling needed if scale matches closely (< 5%)
3238
+ 1f
3239
+ }
3240
+
3241
+ // Final display width: base width * visual scale factor
3242
+ val displayWidth = baseDisplayWidth * visualScaleFactor
3243
+
3244
+ Box {
3245
+ Image(
3246
+ bitmap = finalBitmap.asImageBitmap(),
3247
+ contentDescription = "PDF Page ${index + 1}",
3248
+ contentScale = ContentScale.Fit,
3249
+ modifier = Modifier
3250
+ .width(displayWidth)
3251
+ .aspectRatio(aspectRatio)
3252
+ // Remove graphicsLayer scaling - width modifier already handles the scaling
3253
+ // This prevents double-scaling that caused 50% to appear as 70%
3254
+ )
3255
+
3256
+ // Show small indicator if actively re-rendering at new zoom level (not stale)
3257
+ val renderStartTime = renderingPages[index]
3258
+ val currentTime = System.currentTimeMillis()
3259
+ val isActivelyRendering = renderStartTime != null &&
3260
+ (currentTime - renderStartTime) < RENDERING_TIMEOUT_MS
3261
+
3262
+ if (needsRerender && isActivelyRendering && !isZoomInProgress) {
3263
+ Card(
3264
+ modifier = Modifier
3265
+ .align(Alignment.TopEnd)
3266
+ .padding(8.dp),
3267
+ elevation = 4.dp,
3268
+ backgroundColor = androidx.compose.ui.graphics.Color.White.copy(alpha = 0.9f)
3269
+ ) {
3270
+ CircularProgressIndicator(
3271
+ modifier = Modifier
3272
+ .size(20.dp)
3273
+ .padding(4.dp),
3274
+ strokeWidth = 2.dp
3275
+ )
3276
+ }
3277
+ }
3278
+ }
3279
+ }
3280
+ }
3281
+ } else if (shouldShowPlaceholder) {
3282
+ // Show placeholder during pinch zoom if bitmap not available
3283
+ Box(
3284
+ modifier = Modifier
3285
+ .width((screenWidthDp - 32.dp) * scale)
3286
+ .height((screenWidthDp - 32.dp) * scale * 1.414f),
3287
+ contentAlignment = Alignment.Center
3288
+ ) {
3289
+ CircularProgressIndicator()
3290
+ }
3291
+ } else if (!isScaleAcceptable && isValidBitmap && !(isPinchZoomActive || recentPinchZoom)) {
3292
+ // CRITICAL FIX: Page is at wrong scale during rapid zoom - show loading indicator
3293
+ // BUT only if NOT during pinch zoom or recent pinch zoom
3294
+ // This prevents showing pages at old scales (e.g., 40% when zoomed to 60%)
3295
+ Box(
3296
+ modifier = Modifier
3297
+ .width((screenWidthDp - 32.dp) * scale)
3298
+ .height((screenWidthDp - 32.dp) * scale * 1.4f)
3299
+ .padding(8.dp),
3300
+ contentAlignment = Alignment.Center
3301
+ ) {
3302
+ Column(
3303
+ horizontalAlignment = Alignment.CenterHorizontally
3304
+ ) {
3305
+ CircularProgressIndicator(
3306
+ modifier = Modifier.size(32.dp)
3307
+ )
3308
+ Spacer(modifier = Modifier.height(8.dp))
3309
+ Text(
3310
+ // text = " ${(scale * 100).toInt()}%...",
3311
+ text = "",
3312
+ style = MaterialTheme.typography.caption
3313
+ )
3314
+ }
3315
+ }
3316
+ } else {
3317
+ // Page not loaded yet - check if actively rendering or queued
3318
+ val renderStartTime = renderingPages[index]
3319
+ val currentTime = System.currentTimeMillis()
3320
+ val isActivelyRendering = renderStartTime != null &&
3321
+ (currentTime - renderStartTime) < RENDERING_TIMEOUT_MS
3322
+ val isQueued = queuedPages.value.contains(index)
3323
+
3324
+ // Only show loading if actively rendering (not stale) or queued
3325
+ if (isActivelyRendering || isQueued) {
3326
+ Box(
3327
+ modifier = Modifier
3328
+ .width((screenWidthDp - 32.dp) * scale)
3329
+ .height((screenWidthDp - 32.dp) * scale * 1.4f) // Approximate A4 ratio
3330
+ .padding(8.dp),
3331
+ contentAlignment = Alignment.Center
3332
+ ) {
3333
+ Column(
3334
+ horizontalAlignment = Alignment.CenterHorizontally
3335
+ ) {
3336
+ CircularProgressIndicator(
3337
+ modifier = Modifier.size(32.dp)
3338
+ )
3339
+ Spacer(modifier = Modifier.height(8.dp))
3340
+ Text(
3341
+ text = "Loading page ${index + 1}...",
3342
+ style = MaterialTheme.typography.caption
3343
+ )
3344
+ }
3345
+ }
3346
+ } else {
3347
+ // Page not loading (might be stuck) - show empty space
3348
+ // This prevents stuck loaders
3349
+ Box(
3350
+ modifier = Modifier
3351
+ .width((screenWidthDp - 32.dp) * scale)
3352
+ .height((screenWidthDp - 32.dp) * scale * 1.4f)
3353
+ .padding(8.dp),
3354
+ contentAlignment = Alignment.Center
3355
+ ) {
3356
+ // Empty - page will load when scrolled into view or retried
3357
+ }
3358
+ }
3359
+ }
3360
+
3361
+ // Add page number label
3362
+ Text(
3363
+ text = "Page ${index + 1} of $pageCount",
3364
+ modifier = Modifier.padding(vertical = 8.dp),
3365
+ style = MaterialTheme.typography.caption
3366
+ )
3367
+ }
3368
+ }
3369
+ }
3370
+
3371
+ // Current page indicator (floating badge) - bottom-right corner
3372
+ Card(
3373
+ modifier = Modifier
3374
+ .align(Alignment.BottomEnd)
3375
+ .padding(16.dp),
3376
+ elevation = 4.dp,
3377
+ backgroundColor = androidx.compose.ui.graphics.Color(0xFFF5F5F5), // Light grey background
3378
+ shape = androidx.compose.foundation.shape.RoundedCornerShape(8.dp)
3379
+ ) {
3380
+ Text(
3381
+ text = "Page ${currentPage.value} of $pageCount",
3382
+ modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp),
3383
+ style = MaterialTheme.typography.body2,
3384
+ color = androidx.compose.ui.graphics.Color.Black
3385
+ )
3386
+ }
3387
+ }
3388
+ } // Close Column
3389
+ } else {
3390
+ // Show loading progress during initial load
3391
+ Box(
3392
+ modifier = Modifier.fillMaxSize(),
3393
+ contentAlignment = Alignment.Center
3394
+ ) {
3395
+ Column(
3396
+ horizontalAlignment = Alignment.CenterHorizontally,
3397
+ verticalArrangement = Arrangement.Center,
3398
+ modifier = Modifier.padding(32.dp)
3399
+ ) {
3400
+ CircularProgressIndicator(
3401
+ modifier = Modifier.size(64.dp),
3402
+ color = androidx.compose.ui.graphics.Color(0xFF9C27B0) // Purple color
3403
+ )
3404
+ Spacer(modifier = Modifier.height(24.dp))
3405
+ Text(
3406
+ // text = "${(scale * 100).toInt()}%...",
3407
+ text = "",
3408
+ style = MaterialTheme.typography.h6,
3409
+ color = androidx.compose.ui.graphics.Color.Black
3410
+ )
3411
+ }
3412
+
3413
+ // Page info in bottom-right corner (even during loading)
3414
+ if (pageCount > 0) {
3415
+ Card(
3416
+ modifier = Modifier
3417
+ .align(Alignment.BottomEnd)
3418
+ .padding(16.dp),
3419
+ elevation = 4.dp,
3420
+ backgroundColor = androidx.compose.ui.graphics.Color(0xFFF5F5F5), // Light grey background
3421
+ shape = androidx.compose.foundation.shape.RoundedCornerShape(8.dp)
3422
+ ) {
3423
+ Text(
3424
+ text = "Page 1 of $pageCount",
3425
+ modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp),
3426
+ style = MaterialTheme.typography.body2,
3427
+ color = androidx.compose.ui.graphics.Color.Black
3428
+ )
3429
+ }
3430
+ }
3431
+ }
3432
+ }
3433
+ }
3434
+ }
3435
+ }
3436
+
3437
+ // ============================================================================
3438
+ // PREVIEW FUNCTION
3439
+ // ============================================================================
3440
+
3441
+ @Preview(showBackground = true, name = "PDF Viewer Preview")
3442
+ @Composable
3443
+ fun PdfRendererViewPreview() {
3444
+ MaterialTheme {
3445
+ Box(
3446
+ modifier = Modifier
3447
+ .fillMaxSize()
3448
+ .background(Color.White)
3449
+ ) {
3450
+ PdfRendererView(
3451
+ uri = Uri.EMPTY,
3452
+ initialPageIndex = -1,
3453
+ maxWidthDp = null,
3454
+ maxZoom = PdfViewerConfig.MAX_SCALE_SMALL_SCREEN,
3455
+ screenWidthPercentage = 100f,
3456
+ iconUrls = null,
3457
+ isFullScreen = false,
3458
+ backButtonText = "Back",
3459
+ headerText = "PDF Preview",
3460
+ onFullScreenChange = null,
3461
+ onLeftScreenChange = null,
3462
+ onRightScreenChange = null,
3463
+ onZoomChange = null
3464
+ )
3465
+ }
3466
+ }
3467
+ }