Compose for widgets, RemoteViews underneath

Calendula’s home-screen widgets are written in Jetpack Glance, the Compose-style API for widgets. You declare a GlanceAppWidget, write something that looks like a composable, and attach behaviour with familiar-feeling modifiers. It’s a real relief compared to hand-assembling widget layouts.

Glance is a translation layer on top of the old widget runtime, though. Your composition is compiled down to RemoteViews, a serialized description of a layout that another process (the launcher) inflates and renders. Your app doesn’t have to be running while the widget is on screen, and it never gets the touch events. Glance hides that, but it can’t change it. Three Calendula widget bugs in a row came down to the same thing: the RemoteViews constraints still apply under the Compose surface.

Every tap is a PendingIntent

In a real Compose UI you’d write Modifier.clickable { doThing() } and doThing runs in your process, called straight from the touch event. Glance lets you write almost the same thing, GlanceModifier.clickable { doThing() }, and that resemblance hides most of this post. The launcher still can’t call into your code. Whatever you attach, Glance turns into the PendingIntents RemoteViews has always required. actionStartActivity launches something. actionRunCallback names an ActionCallback class that Glance’s broadcast receiver creates when the tap arrives. Even a plain lambda goes the same way: the intent comes back to your app, and the lambda runs in a background worker that hosts the widget session. However it looks at the call site, a tap sends an intent to a process that may not be running yet, and your code only runs once it’s delivered.

That changes how I read every “make X tappable” request. When the month widget’s arrows and header did nothing (#18), the arrows did have actions. Each fired an actionRunCallback, which Glance resolves by writing the callback’s class name into the intent and creating the class from that name when the tap comes in. A recent release had broken that: R8, which shrinks release builds, found no code that used those classes directly and renamed them, so the lookup failed silently and the tap went nowhere. Taps that launched an activity kept working, since they need no lookup. The fix was a rule that keeps every ActionCallback as it is. The header was the other case: it never had an action at all, so the launcher had nothing to fire until I attached one that opens the app in month view. #20 had that shape. The agenda widget’s header should open your default view, and the action was missing.

Because every day cell needs to open its own date, each one carries its own action. Glance’s clickable makes that look like an ordinary per-item modifier, but underneath it’s still one addressed intent per cell, so a month grid is a grid of intents. An earlier round (#2) fixed exactly this: the month grid wasn’t interactive at all until each day was made to launch itself.

The far side has to be told what “back” means

Because a tap launches an intent rather than navigating, the app has to reconstruct context on arrival. Tapping a day in the agenda widget and tapping a day in the month widget both open a day, but where should Back take you? The widget knows. The freshly started activity doesn’t, unless the action says so.

So actionStartActivity carries “you came from the agenda context” along with “open this day,” and the app rebuilds a back stack from that: day → agenda → your default view, or day → month → default. It looks like normal navigation to the user. Under the hood the app trusts a breadcrumb the widget packed into the launch. A widget can’t hand over live navigation state; with or without Glance, all it can serialize is data.

“Upcoming” has to actually mean upcoming

The last one wasn’t about interaction. The agenda widget is titled Upcoming, yet it listed every event of the day, finished ones included (#12). In a normal Compose list you’d filter as the list recomposes. A widget can’t do that: what the launcher renders is fixed when the composition is snapshotted to RemoteViews, and nothing is recomputed as you scroll. So “don’t show past events” has to be a property of the data you build before Glance serializes it. In Calendula that’s a PastEventDisplay preference: hide finished events outright, or dim them in place for people who still want them visible.

What I took from it

Glance is a real improvement, and declaring widgets in Compose beats the old ceremony. Underneath, though, there’s still no process of yours and no live view tree, only data you snapshot and actions you address in advance, rendered by someone else. Once I stopped asking “how do I handle this tap” and started asking “what action should this fire, and what does the far side need to know,” the three bugs stopped looking separate.