Skip to content

fix(android): stop a runtime Zoom change from crashing pdfium, and unify Zoom across platforms - #20

Merged
michaelstonis merged 4 commits into
mainfrom
fix/android-zoom-crash
Aug 25, 2026
Merged

fix(android): stop a runtime Zoom change from crashing pdfium, and unify Zoom across platforms#20
michaelstonis merged 4 commits into
mainfrom
fix/android-zoom-crash

Conversation

@michaelstonis

Copy link
Copy Markdown
Collaborator

Fixes the reported Android crash where setting Zoom at runtime and then tapping the page kills the process with SIGSEGV, and brings the Zoom lifecycle 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 is this._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.checkLinkTapped unconditionally, which does:

page = pdfFile.getPageAtOffset(-currentYOffset + tapY, zoom)   // stale offset x new zoom
pdfFile.getPageLinks(page, ...)                                 // -> PdfiumCore.getPageLinks

That resolves a page the renderer never opened. In ahmer-pdfium 1.9.2:

private long pagePtr(int index) {
    PageCount pc = doc.getPageCache().get(index);
    return pc != null ? pc.getPagePtr() : -1L;   // unguarded sentinel
}

getPageLinks passes that -1 to nativeGetPageLinks(long) with no check, pdfium dereferences (FPDF_PAGE)-1, and the process dies. Confirmed on device:

signal 11 (SIGSEGV), code 1 (SEGV_MAPERR), fault addr 0x00ffffffffffffff
#00 libpdfium.so
#01 FPDFLink_Enumerate
#02 Java_com_ahmer_pdfium_PdfiumCore_nativeGetPageLinks

(The fault address is -1 with the top byte stripped by ARM64 pointer tagging.)

zoomWithAnimation — the workaround in the report — avoids this because its animation listener calls zoomCenteredTo() per frame and loadPages() + performPageSnap() at the end.

Correction to the repro steps: the zoom must decrease. A larger zoom makes getPageAtOffset resolve 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, then LoadPages() and PerformPageSnap().

Plus a guard for the wider hazard: the unguarded -1 is 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 own getPageAtOffset. PdfFile.OpenPage takes the same lock PdfiumCore holds 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

Zoom had drifted into different lifecycles per platform. One contract is now documented on IPdfView.Zoom and both implementations share the same state machine behind it. Three inconsistencies close:

  • A reload dropped the zoom on both platforms. Android's recycle() resets the native zoom to 1; iOS re-fits on a new Document. Both now capture and restore. Assigning a new Source is the deliberate exception and starts fitted.
  • ApplyFitPolicy silently reset the zoom on iOS, because SetManualScale writes ScaleFactor directly.
  • Rotation reset the zoom on iOS but not Android. PdfKit re-fits inside LayoutSubviews, so the pre-fit scale is only readable before base runs; added a WillLayoutSubviewsAction hook to capture there.

MinZoom/MaxZoom now re-clamp the current level on both platforms, and the iOS layout hooks are cleared on Dispose.

Verification

Built clean for net10.0-android and net10.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:

  • Original repro (zoom in, scroll, zoom out, tap): crashes before, survives after
  • 4 rounds of hard flings with immediate taps plus full zoom cycles: 0 crashes
  • Reload via Fit Policy preserves zoom and page; document switch lands fitted

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 (simctl has 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

michaelstonis and others added 3 commits August 24, 2026 17:10
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>
Copilot AI lite review requested due to automatic review settings August 24, 2026 22:12

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +155 to +165
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>
Copilot AI review requested due to automatic review settings August 25, 2026 14:31

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

  • LoadDocument returns immediately when _source is null, which means setting Source = null leaves the previous document visible and keeps _pageCount / _currentPage / _openedPages state from the prior document. Since IPdfView.Source is nullable (and iOS clears _pdfView.Document when _source is 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 different Source, but the method still uses the previous document’s _currentPage as pageToRestore. That value can be out of range for the new document and is also passed into configurator.DefaultPage(...) before any bounds check. Consider only restoring the page when doing an internal reload (i.e., when preserveZoom is true), and otherwise fall back to DefaultPage.
        // Store current page to restore after reload
        int pageToRestore = _currentPage;

@michaelstonis
michaelstonis merged commit 05bb637 into main Aug 25, 2026
1 check passed
@michaelstonis
michaelstonis deleted the fix/android-zoom-crash branch August 25, 2026 14:51
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

Setting Zoom after document is displayed on Android results in app crash on click

2 participants