fix(android): stop a runtime Zoom change from crashing pdfium, and unify Zoom across platforms - #20
Conversation
PDFView.zoomTo() is a bare field assignment: it neither re-clamps the scroll offsets nor reloads the rendered parts. Assigning Zoom therefore left the control holding offsets that describe the *previous* zoom level, which is why the document visibly failed to re-render. The next tap then runs AhmerPdfViewer's link hit-test, which maps the touch through the new zoom and the stale offset, resolves a page the renderer never opened, and asks PdfiumCore for that page's links. PdfiumCore.pagePtr() answers -1 for any page missing from its cache and hands that -1 to pdfium as an FPDF_PAGE with no guard, so the process dies with SIGSEGV at 0xffffffff. Apply zoom the way the library applies its own instead: ZoomCenteredTo() to recompute and clamp the offsets around a pivot, then LoadPages() and PerformPageSnap(). That is the same pair ZoomWithAnimation drives on each animation frame and at its end, which is why animating the zoom sidestepped the crash. Two supporting changes: - Zoom is now held in the wrapper and re-applied whenever the control resets itself. MoveTo() no-ops without a loaded document and the pivot needs real bounds, so a Zoom set before either would otherwise be dropped; recycle() also resets the native zoom to 1 on every reload, silently discarding the caller's level. A new Source still starts fitted. - Pre-open every page a touch inside the viewport could resolve to, computed from the library's own getPageAtOffset. This makes the -1 sentinel unreachable after a fling to a page that has not rendered yet, not just after a zoom. PdfFile.OpenPage takes the lock PdfiumCore holds for the whole of a native page render, so the sweep memoizes what it has covered to keep off that lock on the UI thread. Reproduced and verified on an API 35 emulator: zooming in, scrolling, then zooming back out and tapping kills the process before this change and survives after. Note the zoom must decrease — a larger zoom resolves a lower page, which is always already open. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Android and iOS had drifted into different Zoom lifecycles, so the same assignment behaved differently depending on the platform. Document one contract on IPdfView.Zoom and give both implementations the same state machine behind it (ReadZoom / CaptureZoom / ReclampZoom / SyncZoom / TryApplyZoom over a stored level plus a needs-apply flag). Three real inconsistencies close as a result: - A reload dropped the zoom on both platforms. Android's recycle() resets the native zoom to 1; iOS re-fits when a new Document is assigned. Both now capture the level on screen first and restore it after. A new Source is the deliberate exception and starts fitted, otherwise switching documents while zoomed in lands the reader in the page margin. - ApplyFitPolicy silently reset the zoom on iOS, because SetManualScale writes ScaleFactor directly. It now captures first. - Rotation reset the zoom on iOS but not on Android. PdfKit re-fits inside LayoutSubviews, so the pre-fit scale is only readable before base runs; NativePdfView gained a WillLayoutSubviewsAction hook to capture there, and the existing post-layout hook restores. ReadZoom/CaptureZoom divide by the fit scale ScaleFactor was last established against rather than the live one, since only that pairing is valid mid-re-fit. MinZoom/MaxZoom now re-clamp the current level on both platforms, and the iOS layout hooks are cleared on Dispose so a teardown layout pass cannot call back into a disposed wrapper. Verified on an API 35 emulator and an iOS 26.2 simulator. Rotation could not be exercised on the simulator — simctl has no rotate command. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Cycles 1.0x / 1.5x / 2.0x / 3.0x so the reported crash is reachable by hand: zoom in, scroll into the document, zoom back out, tap the page. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Fixes an Android crash in AhmerPdfViewer when Zoom is changed at runtime (and then the user taps), and standardizes IPdfView.Zoom semantics across Android and iOS so zoom persists consistently through reload/fit/resize events (except when switching Source, which intentionally resets to fitted).
Changes:
- Android: apply zoom via
ZoomCenteredTo()+LoadPages()+PerformPageSnap()and proactively open pages that a tap inside the viewport could resolve to. - iOS: introduce a stored zoom state machine that survives PdfKit re-fitting (layout/rotation), reloads, and fit-policy changes.
- Samples: add a Zoom toggle UI to exercise the Android regression scenario.
Reviewed changes
Copilot reviewed 5 out of 5 changed files in this pull request and generated 1 comment.
Show a summary per file
| File | Description |
|---|---|
| src/MauiNativePdfView/Platforms/iOS/PdfViewiOS.cs | Reworks zoom handling to store/restore relative zoom across PdfKit re-fit/reload/layout events. |
| src/MauiNativePdfView/Platforms/Android/PdfViewAndroid.cs | Reworks zoom application to avoid pdfium crash and adds visible-page pre-open sweep to prevent tapping unopened pages. |
| src/MauiNativePdfView/Abstractions/IPdfView.cs | Documents a unified cross-platform Zoom contract (relative to fit scale, persisted across reload/resize). |
| samples/MauiPdfViewerSample/PdfTestPage.xaml.cs | Adds a regression-oriented “Zoom then tap” button handler for Android. |
| samples/MauiPdfViewerSample/PdfTestPage.xaml | Adds the Zoom toggle UI row/button to the sample page. |
Suppressed comments (1)
src/MauiNativePdfView/Platforms/Android/PdfViewAndroid.cs:223
- EnsureVisiblePagesOpen() can be reached via callbacks/posted work; after Dispose() runs, accessing members on the disposed Java peer can throw (e.g., ObjectDisposedException). Adding a cheap Handle==IntPtr.Zero guard at the start makes this method safe if it runs late.
private void EnsureVisiblePagesOpen()
{
var pdfFile = _pdfView.PdfFile;
if (pdfFile == null)
return;
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| private void SyncZoom() | ||
| { | ||
| if (!_zoomNeedsApply) | ||
| return; | ||
|
|
||
| _pdfView.Post(() => | ||
| { | ||
| if (_zoomNeedsApply && TryApplyZoom(_zoom)) | ||
| _zoomNeedsApply = false; | ||
| }); | ||
| } |
SyncZoom() and ApplyPageAlignment() both hand work to the view's message queue, and the library's listeners can fire during teardown. Either can run after Dispose() has released the Java peer, at which point touching a bound member throws ObjectDisposedException on the UI thread. Track disposal in the wrapper — as the iOS implementation already does — and bail out of the posted callbacks, EnsureVisiblePagesOpen and the alignment pass when it is set. Dispose() is now idempotent and raises the flag before releasing the peer, so work already queued sees it. Raised by review on #20. The exposure predates this branch, since ApplyPageAlignment has always posted into code that reads the peer, but this change adds two more deferred paths so it is worth closing here. Verified on an API 35 emulator: the crash repro still passes, and rotating (which tears down and rebuilds the handler) plus backgrounding and resuming produces no ObjectDisposedException and no native crash. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 5 out of 5 changed files in this pull request and generated no new comments.
Suppressed comments (2)
Previously missed (2) — in code that hasn't changed since the last review.
src/MauiNativePdfView/Platforms/Android/PdfViewAndroid.cs:524
LoadDocumentreturns immediately when_sourceis null, which means settingSource = nullleaves the previous document visible and keeps_pageCount/_currentPage/_openedPagesstate from the prior document. SinceIPdfView.Sourceis nullable (and iOS clears_pdfView.Documentwhen_sourceis null), Android should also unload/reset state when the source is cleared.
private void LoadDocument(bool preserveZoom = true)
{
if (_source == null)
return;
src/MauiNativePdfView/Platforms/Android/PdfViewAndroid.cs:526
LoadDocument(preserveZoom: false)is used when switching to a differentSource, but the method still uses the previous document’s_currentPageaspageToRestore. That value can be out of range for the new document and is also passed intoconfigurator.DefaultPage(...)before any bounds check. Consider only restoring the page when doing an internal reload (i.e., whenpreserveZoomis true), and otherwise fall back toDefaultPage.
// Store current page to restore after reload
int pageToRestore = _currentPage;
Fixes the reported Android crash where setting
Zoomat runtime and then tapping the page kills the process withSIGSEGV, and brings theZoomlifecycle into line across Android and iOS.The crash
PDFView.zoomTo(float)in ahmer-pdfviewer 2.0.1 is a bare field assignment — the whole method body isthis._zoom = value. It doesn't re-clamp the scroll offsets, doesn't reload the rendered parts, doesn't invalidate. That's why the document visibly failed to re-render at the new level.The control is then left holding offsets that describe the previous zoom. The next tap runs
DragPinchManager.checkLinkTappedunconditionally, which does:That resolves a page the renderer never opened. In ahmer-pdfium 1.9.2:
getPageLinkspasses that-1tonativeGetPageLinks(long)with no check, pdfium dereferences(FPDF_PAGE)-1, and the process dies. Confirmed on device:(The fault address is
-1with the top byte stripped by ARM64 pointer tagging.)zoomWithAnimation— the workaround in the report — avoids this because its animation listener callszoomCenteredTo()per frame andloadPages()+performPageSnap()at the end.Correction to the repro steps: the zoom must decrease. A larger zoom makes
getPageAtOffsetresolve a lower page, which is always already open. Zooming in from the top of a document does not crash.The fix
Apply zoom the way the library applies its own —
ZoomCenteredTo()to recompute and clamp the offsets around a pivot, thenLoadPages()andPerformPageSnap().Plus a guard for the wider hazard: the unguarded
-1is reachable by any tap on a page pdfium hasn't opened yet, not just after a zoom (e.g. a fling to a distant page, tapped before the background render thread gets there). The wrapper now pre-opens exactly the pages a touch inside the viewport could resolve to, computed from the library's owngetPageAtOffset.PdfFile.OpenPagetakes the same lockPdfiumCoreholds for the whole of a native page render, so the sweep memoizes what it has covered to stay off that lock on the UI thread.Residual gap: a page that genuinely fails to open is recorded as failed by the library and will still crash on tap. That page can't render either, so it's a corrupt-document case rather than a timing one. No iOS counterpart — PdfKit has no equivalent sentinel.
Zoom consistency
Zoomhad drifted into different lifecycles per platform. One contract is now documented onIPdfView.Zoomand both implementations share the same state machine behind it. Three inconsistencies close:recycle()resets the native zoom to 1; iOS re-fits on a newDocument. Both now capture and restore. Assigning a newSourceis the deliberate exception and starts fitted.ApplyFitPolicysilently reset the zoom on iOS, becauseSetManualScalewritesScaleFactordirectly.LayoutSubviews, so the pre-fit scale is only readable beforebaseruns; added aWillLayoutSubviewsActionhook to capture there.MinZoom/MaxZoomnow re-clamp the current level on both platforms, and the iOS layout hooks are cleared onDispose.Verification
Built clean for
net10.0-androidandnet10.0-ios; each of the three commits builds independently.Android emulator (API 35, arm64) — reverted just the library to confirm the repro was valid, got the tombstone above, then reapplied:
iOS simulator (iPhone 17 Pro, iOS 26.2) — zoom applies, fit-policy change preserves the level, document switch lands fitted.
Rotation could not be exercised on the simulator (
simctlhas no rotate command), so that branch of the iOS layout hook is reasoned-correct but unverified.Worth doing separately
The upstream
pagePtr()should return early rather than pass a sentinel into JNI — worth an issue against AhmerPdfium.🤖 Generated with Claude Code