Skip to content

fix(widget): stop the widget refresh crashing on a null PendingResult - #66

Merged
SiteRelEnby merged 2 commits into
mainfrom
fix/widget-refresh-goasync-npe
Aug 28, 2026
Merged

fix(widget): stop the widget refresh crashing on a null PendingResult#66
SiteRelEnby merged 2 commits into
mainfrom
fix/widget-refresh-goasync-npe

Conversation

@SiteRelEnby

Copy link
Copy Markdown
Collaborator

Fixes the two field crashes reported through Play Console.

NullPointerException: Attempt to invoke virtual method
'void android.content.BroadcastReceiver$PendingResult.finish()'
on a null object reference
  at WidgetRefreshDispatchKt$refreshWidgets$1.invokeSuspend (WidgetRefreshDispatch.kt:47)

Root cause

goAsync() hands out the receiver's PendingResult exactly once per dispatch and clears its own reference. A second caller in the same onReceive gets null.

GlanceAppWidgetReceiver.onUpdate calls goAsync() for its own compose. All six of our receivers do:

super.onUpdate(context, appWidgetManager, appWidgetIds)   // Glance takes the PendingResult
refreshWidgets(context, appWidgetIds, RefreshAction())    // this gets null

So pending was always null, and pending.finish() threw out of the finally. An exception escaping a coroutine launched into a bare CoroutineScope has nowhere to go, so it takes the process down.

Confirmed against the compiled Glance 1.1.0 class rather than inferred from docs: 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; 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 only PendingResult - 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 WorkManager work 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 pending is 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:testPlayReleaseUnitTest green. goAsync appears 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:

  • Add each widget type; they render and refresh without crashing
  • Force an update (rotate, or wait a tick) repeatedly: no crash
  • Tap a quick-switch widget chip: the switch still happens
  • adb logcat -s WidgetRefresh: no "could not finish broadcast" spam

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.
@SiteRelEnby
SiteRelEnby enabled auto-merge August 28, 2026 00:49
@SiteRelEnby
SiteRelEnby merged commit 91248d4 into main Aug 28, 2026
1 check passed
@SiteRelEnby
SiteRelEnby deleted the fix/widget-refresh-goasync-npe branch August 28, 2026 00:54
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