Skip to content

fix: guard app bar offset listeners against a null view binding - #968

Open
herrerad85 wants to merge 1 commit into
eddyizm:developmentfrom
herrerad85:fix/appbar-offset-listener-npe-966
Open

fix: guard app bar offset listeners against a null view binding#968
herrerad85 wants to merge 1 commit into
eddyizm:developmentfrom
herrerad85:fix/appbar-offset-listener-npe-966

Conversation

@herrerad85

Copy link
Copy Markdown
Contributor

What this fixes

Issue #966. A NullPointerException thrown from an AppBarLayout offset listener on a screen the user has already left. The pattern dates to 2021 and predates this fork.

Why it happens

All twelve of these layouts carry snap in their scroll flags. Releasing a partly scrolled app bar starts BaseBehavior's offset ValueAnimator to settle it to the nearest snap position.

That animator is ticked by the thread's AnimationHandler rather than by the view hierarchy, so detaching the view does not cancel it, and removeOnOffsetChangedListener is never called anywhere in the app, so the listener stays registered as well. Navigate away while the bar is still settling and it keeps calling onOffsetChanged after onDestroyView has nulled bind, which throws.

One correction to the analysis in the issue: it is not a layout pass driving the callback. Deobfuscated stack from a debug build, trimmed:

AlbumCatalogueFragment.lambda$initAppBar$1(AlbumCatalogueFragment.java:133)
AppBarLayout.onOffsetChanged(AppBarLayout.java:898)
AppBarLayout$BaseBehavior.setHeaderTopBottomOffset(AppBarLayout.java:2067)
HeaderBehavior.setHeaderTopBottomOffset(HeaderBehavior.java:158)
AppBarLayout$BaseBehavior$1.onAnimationUpdate(AppBarLayout.java:1651)
android.animation.ValueAnimator.doAnimationFrame(ValueAnimator.java:1589)
android.animation.AnimationHandler$1.doFrame(AnimationHandler.java:106)
android.view.Choreographer$CallbackRecord.run(Choreographer.java:1959)

The AppBarLayout reported isAttachedToWindow() as false at every post teardown call.

Reproducing it

It needs a drag release rather than a fling. A fling runs the bar to a rest position and finishes the animator before you can navigate away, which is why this is easy to miss.

  1. Library, Albums, See all
  2. Drag the list up so the header is caught partway, then lift
  3. Press back while it is still settling

What the change does

Twelve fragments register an offset listener and every one of them reads bind inside it. Each gets the early return already used elsewhere in this code base.

PlaylistPageFragment's landscape scroll sync read bind.playlistInfoScrollView inside a RecyclerView scroll listener. It now holds the view in a local, which is what AlbumPageFragment already does for the same block.

DirectoryFragment, IndexFragment and SongListPageFragment wrapped statements in initAppBar in five bind != null checks that can never be false. Each of those methods dereferences bind unguarded on its first line, so a null bind would already have thrown before reaching them. Removed, which also makes those three match the other nine.

What it does not do

It does not unregister the listener. The animator keeps ticking and the callback still fires after the view is gone, it just returns early instead of throwing. Unregistering would mean holding a listener reference as a field in twelve fragments, which is a wider change than this crash calls for.

Verification

Galaxy S25 Ultra on Android 16, debug builds of the same commit with and without the change, same scripted gesture, same device, same session.

Build Result
unmodified process died on 4 of 5 attempts
this branch survived 6 of 6

IndexFragment separately survived 5 of 5 with the animator duration scale raised to 10, which widens the window the crash needs.

The landscape scroll sync was checked on a playlist in landscape: the info pane still scrolls with the list, across four back navigations and three rotations taken mid scroll.

These layouts set scroll flags including snap, so releasing a partly scrolled
collapsing app bar starts BaseBehavior's offset ValueAnimator to settle it to
the nearest snap position. That animator is ticked by the thread's
AnimationHandler rather than by the view hierarchy, so detaching the fragment
view does not cancel it, and the listener is never unregistered. Navigate away
while it is still settling and it keeps calling onOffsetChanged after
onDestroyView has nulled bind, which throws.

Twelve fragments register such a listener and every one of them reads bind
inside it. Each gets the early return already used elsewhere in this code base.

Reproduced on a Galaxy S25 Ultra running Android 16, on a debug build. It needs
a drag release rather than a fling, because a fling runs the bar to a rest
position and finishes the animator before you can navigate away. Same scripted
gesture, same device, same session: the unmodified build died on 4 of 5
attempts, this branch survived 6 of 6.

PlaylistPageFragment's landscape scroll sync read bind.playlistInfoScrollView
inside a RecyclerView scroll listener. It now holds the view in a local, which
is what AlbumPageFragment already does for the same block. Checked in landscape
that the info pane still scrolls with the list.

DirectoryFragment, IndexFragment and SongListPageFragment wrapped statements in
initAppBar in a bind != null check. Each of those methods dereferences bind
unguarded on its first line, so those checks could never be false. Removed.
@coderabbitai

coderabbitai Bot commented Aug 8, 2026

Copy link
Copy Markdown
Contributor

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: a839ce0d-9ff1-49a4-bdb2-87b92593f00d

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant