An Android widget cannot render JSON. RemoteViews takes a TextView, and a TextView takes a string. So somewhere between "here is a nested object" and "here is a 60-character line", you need a formatting step.

Most widget apps solve this with a visual editor, or with code, or with Tasker. I wanted the third option nobody offers: a format string you can type on a phone keyboard in ten seconds.

This post is the design of that little language, and the two mistakes I made building it.

The constraint

The format has to do five things, and nothing else:

  1. Pick a value out of a nested document: {main.temp}
  2. Project a list onto lines: {stations.*.brand}
  3. Repeat a row per item: #each orders … #end
  4. Do one arithmetic operation, because APIs return cents: {price / 1000}
  5. Lay out multiple lines: \n, \t

Everything else — loops with accumulators, functions, conditionals over arrays — is out of scope. If your widget needs JavaScript, you don't want a widget, you want a dashboard.

The grammar

Eight forms, no operators to memorise:

Form Meaning
{a.b.c} dotted path, arrays index with a number
{a.*.b} project: map b over every item of a
{a[0:5].b} slice before projecting (also {a(3)} for the first three)
{price / 1000} one operation: + - * /
{?missing} render nothing instead of the placeholder
{missing ?: "n/a"} fallback text
`{status \ 1: On, *: Off}`
#each items where qty > 0 … #end repeat a block per item

A missing path renders —, not an error. That one decision matters more than it looks: a sensor going unavailable should not blank the whole tile, and an error string in a widget is worse than a dash.

Two decisions that paid off

Resolve exactly once. The scanner walks the format left to right and substitutes each fragment a single time. A value that itself looks like JSON is emitted verbatim, never re-parsed. This kills a whole class of bugs where user data gets interpreted as template syntax.

No regex for blocks. Matching #each … #end with a regex works until it doesn't: nested blocks, #elseif, and an #end inside a string literal all break it. A single pass with a depth counter is shorter than the regex, and correct.

fun render(text: String, cursor: Int = 0, depth: Int = 0): String? { if (depth > MAX_DEPTH) return null val out = StringBuilder() var i = cursor while (i < text.length) { when { text.startsWith("#each", i) -> { /* read header, find matching #end, recurse per item */ } text.startsWith("#if", i) -> { /* pick a branch, recurse */ } text[i] == '{' -> { /* find '}', resolve one expression, append */ } else -> out.append(text[i++]) } } return out.toString() } 

The trap: braces are not always braces

Here is the bug that cost me a release.

Users write {{field}}. They mean {field}. Doubled braces are a de-facto standard from every other templating language, so this mistake is inevitable — and silently rendering {21.4} is worse than failing loudly.

So I added a check with a regex:

private val TEMPLATE_MARK = Regex("\\{\\{[^{}]*}}") 

It compiled. The unit tests passed. The app crashed on launch on a real device with ExceptionInInitializerError.

Android's java.util.regex is backed by ICU, not OpenJDK's engine, and ICU is stricter: a literal } outside a character class that is not part of a valid interval quantifier is a syntax error. On the desktop JVM, }} is tolerated. On the device, the class fails to initialise — and because it's a top-level val, it fails when the class loads, not when the regex is used.

The fix is one escape per brace:

private val TEMPLATE_MARK = Regex("\\{\\{[^{}]*\\}\\}") 

The lesson I now apply everywhere: a green JVM test suite does not mean your regexes compile on Android. If a regex contains a literal brace, escape it. I added a test that scans every regex literal in the source tree and fails the build on an unescaped { or } — it is ten lines, and it will never let that class of bug through again.

Cap everything, because widgets are not views

Two limits, both learned from RemoteViews:

  • 20 rows per loop or projection. Beyond that, a ... (+N more) line. A 400-item array will happily crash the launcher, and nobody reads row 90.
  • 2000 characters of output. A widget is four lines tall.

Rendering is not the place to be generous.

Was it worth it?

Yes, for one reason: the format string is the documentation. When someone shares a tracker, they share the source URL and that string. You can read a tile's definition in one line:

{attributes.friendly_name}\n{state}{attributes.unit_of_measurement} 

No AST to export, no editor state, no version skew.

The honest downside: non-developers look at {a.*.b} and stop. The fix is not more syntax — it is presets that hide it. That is the next thing I am building.

If you want to see the result: I ship it as an Android app called Zync — that link is the only promotion in this post, the syntax above is the actual subject.

The full grammar reference is at zync.dallah.dev/docs.