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