fix(widget): stop the widget refresh crashing on a null PendingResult - #66
Merged
Conversation
Two crashes reported from the field:
NullPointerException: Attempt to invoke virtual method
'void android.content.BroadcastReceiver$PendingResult.finish()'
on a null object reference
at WidgetRefreshDispatch.kt:47
goAsync() hands out the receiver's PendingResult exactly once per dispatch and
clears its own reference, so a second caller in the same onReceive gets null.
GlanceAppWidgetReceiver.onUpdate calls goAsync() for its own compose, and all
six receivers here call refreshWidgets after super.onUpdate(), so ours always
gets null. Treating it as non-null threw out of the finally block, and an
exception escaping a coroutine launched into a bare scope takes the process
with it.
Confirmed against the compiled Glance 1.1.0 class rather than inferred:
GlanceAppWidgetReceiver.onUpdate invokes CoroutineBroadcastReceiverKt.goAsync,
which calls BroadcastReceiver.goAsync() and finishes the result in its own
continuation. Glance wraps its finish() in a catch and logs; ours did not.
finish() is now null-safe, and the whole call is wrapped since it also throws
if the broadcast has already been finished.
This does not restore the keep-alive the original comment claimed, because
these callers never had it: Glance holds the only PendingResult. A refresh cut
short by process death leaves a stale widget until the next tick, which is what
already happens today. The comment now says so instead of describing a
protection that is not there, and names the durable fix (enqueue the refresh as
expedited work rather than running it in the receiver) for when someone picks
it up.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes the two field crashes reported through Play Console.
Root cause
goAsync()hands out the receiver'sPendingResultexactly once per dispatch and clears its own reference. A second caller in the sameonReceivegetsnull.GlanceAppWidgetReceiver.onUpdatecallsgoAsync()for its own compose. All six of our receivers do:So
pendingwas always null, andpending.finish()threw out of thefinally. An exception escaping a coroutine launched into a bareCoroutineScopehas nowhere to go, so it takes the process down.Confirmed against the compiled Glance 1.1.0 class rather than inferred from docs:
GlanceAppWidgetReceiver.onUpdateinvokesCoroutineBroadcastReceiverKt.goAsync, which callsBroadcastReceiver.goAsync()and finishes the result in its own continuation. Glance wraps itsfinish()in a catch and logs; this code did not.The fix
finish()is now null-safe, and the whole call is wrapped, since it also throws if the broadcast was already finished.What this does not fix, deliberately
The original comment said
goAsync()"keeps the process alive until finish() is called". These callers never had that - Glance holds the onlyPendingResult- so the keep-alive has been inert since it was written, and this change doesn't restore it.That's a real limitation but a mild one: a refresh cut short by process death leaves a stale widget until the next update tick. No lost data, and it's what already happens today, so nothing regresses.
The comment now describes the actual behaviour instead of a protection that isn't there, and names the durable fix: enqueue the refresh as expedited
WorkManagerwork rather than running it in the receiver, which the repo already does for sync. That's a design change, not a crash fix, so it's out of scope here.On the crash count
Worth flagging: since
pendingis always null, this should fire on every widget update for every user with a widget, not twice. Two reports probably means low widget adoption and/or crash reporting being opt-in - but if that seems too quiet, it's worth a second look at whether something else gates it.Introduced in
d3ec9ed(2026-07-14, "correctness batch from the second review"), so it has been live since 1.2.x.Testing
:app:assemblePlayRelease+:app:testPlayReleaseUnitTestgreen.goAsyncappears nowhere else in the codebase, so this is the only instance of the pattern.Device checklist - needs a widget actually placed on a home screen:
adb logcat -s WidgetRefresh: no "could not finish broadcast" spam