Tags AndroidwidgetsRemoteViewsapp developmentdebugging
All posts

Android

Android widgets: a working preview does not mean a working widget

I shipped the same broken Android widget four times. The preview looked right and the build passed, but adding it to the home screen failed every time.

August 16, 2026 5 min

While reworking the widgets in my Android app, I shipped the same bug four builds in a row.

Every build passed. The preview in the widget picker looked fine. And every time I dropped the widget onto the home screen, it said “Can’t load widget.”

The preview and the real thing run different code

This is the single most important fact about Android widgets.

What you see in the widget picker is the previewLayout, inflated as a layout and nothing more. The widget on your home screen inflates that same layout and then runs your provider code on top of it, the code that fills in values, tints things, and wires up clicks.

So anything wrong in the provider is invisible to the preview. Always.

I did not know that, and I was treating “the preview looks right” as a pass signal.

First cause: a tag I was not allowed to use

RemoteViews cannot inflate arbitrary views. Only classes marked with @RemoteView are permitted.

The plain <View> we all reach for does not carry that mark. I had used one as the empty half of a progress bar:

<View
    android:layout_width="0dp"
    android:layout_weight="1" />

That single element took the whole widget down. <include> is out for the same reason.

⚠️ The important part is that neither aapt nor gradle catches this. Compilation finishes without a word. You only find out on a device.

Second cause: I was calling a method that does not exist

I fixed the tag and shipped again. The preview came back, so I assumed I was done.

It still died on the home screen.

This time it was the Kotlin side:

views.setFloat(id, "setLayoutWeight", ratio)

RemoteViews resolves the method name in setInt / setFloat later, from the string you passed. And View has no setLayoutWeight. So it blows up at the exact moment the widget is applied to the screen.

If you want a proportional bar, use a <clip> drawable and fill it with setImageLevel(0..10000). That method actually exists.

There is a reason the platform works this way. A widget lives in the launcher’s process, not yours, so the system cannot simply hand it your objects. It ships a recipe instead: a layout plus a list of operations to replay. The operations carry method names as strings, and those names are matched against the real view only when the launcher applies them. That is also why the permitted-view list exists at all. Knowing this, the failure mode stops being surprising and starts being predictable.

The mistake I repeated

The two causes live in different layers. One is the layout, the other is provider code.

Both times, because the symptom read the same, I went back and looked at the same layer. I fixed the layout, reported “only permitted tags remain,” and called it verified. That was an answer about the first layer, not the second.

Identical symptoms do not guarantee identical causes. Especially when the failure mode is something vague like “it doesn’t appear.”

So I built something that counts

Writing it in a doc does not stop it, because the compiler waves it through.

So I wrote a checker:

pnpm widget:check

It does two things:

  • compares every tag in the widget layouts against the permitted list
  • scans the Kotlin for calls to methods that do not exist, like setLayoutWeight

⚠️ And I put the bug back to confirm the checker actually catches it. I re-added a <View> and watched it exit 1. Otherwise you end up with a checker that only ever passes and counts nothing.

It strips comments before scanning, because a <View> mentioned inside a comment is a false positive. I know because it flagged one.

Android widget data only refreshes while the app runs

In the same batch, one widget went blank while its sibling was fine. The all-tasks widget showed everything; the today widget was empty.

The cause was when the data gets written. The file the widgets read is only refreshed while the app runs. The background worker just re-reads the same file.

So after midnight, yesterday’s data is still sitting there. The today widget never checked the date, only the time, so it filtered every one of yesterday’s items out as “already past” and rendered an empty list. The all-tasks widget has no such filter, which is why it looked healthy.

I added the date check, and when the data is stale the widget now says:

Open the app to load today

“Nothing today” and “I couldn’t load today” call for different actions from the reader. The first means rest; the second means open the app. An unexplained empty list just reads as broken.

How to tell which layer you are in

If you hit this, the fastest split is to ask what the preview does.

If the picker preview is also broken, the problem is in the layout: a forbidden tag, a missing resource, a bad drawable reference. If the preview renders but the placed widget fails, the layout is fine and the problem is in the provider: a method name that does not resolve, a resource id that is not in that layout, or a bitmap too large to cross the process boundary.

That one question saved me more time than anything else once I finally asked it.

What I would take away

  • A widget preview only verifies the layout. It never runs your provider code
  • <View> and <include> are unavailable in RemoteViews. Use <ImageView> for spacers
  • Method names in setInt / setFloat are resolved at runtime. A typo or a missing method will not fail the build
  • Mistakes that survive compilation are only stopped by something that counts them

Scope

This happened on 2026-08-16, in an Expo SDK 56 (RN 0.85) Android app. Builds were local gradle, and verification was on a physical Samsung device.

⚠️ The checker only guards against the wrong answers I have already hit. These three are surely not the only methods RemoteViews rejects. I set it up so new ones get added to the list as they turn up.

Previous People who keep breaking habits are already counting the tries