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:
- Pick a value out of a nested document:
{main.temp} - Project a list onto lines:
{stations.*.brand} - Repeat a row per item:
#each orders … #end - Do one arithmetic operation, because APIs return cents:
{price / 1000} - 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.
