Every affirmation app I tried was a quote library. Tap through somebody else’s motivational sentences, get a notification you dismiss, pay monthly. What I actually wanted was one line — the thing I’m working on this month — on the lock screen, where I’d see it forty times a day without opening anything, and a way to keep it from going invisible.
Mantra is that: you write the sentence, it becomes your wallpaper, and every morning it arrives on a fresh illustrated background so the eye doesn’t learn to skip it.
Table of contents
Open Table of contents
The keyword gap that named the app
Before writing code I did the store research, and it was unusually clear. The affirmation category leaders on Google Play (a couple of them past five and ten million installs) have no automatic wallpaper feature. The wallpaper-changer category (seven to ten million installs each) has no affirmations, and sits at 3.4–4.2 stars. Nobody was in the middle. “Affirmation wallpaper” was the phrase, and “Mantra: Affirmation Wallpaper” — 29 characters — became the Play title.
“Mantra” itself, incidentally, is a dead Play keyword: it returns chanting and meditation apps. It’s a brand, not a search term, and it takes exactly the space a brand should.
The bug: setting a wallpaper recreates your Activity
This is the one worth the price of the post. On Android 12+, calling WallpaperManager.setBitmap() (or the stream variant) triggers Material You to re-extract dynamic colours from the new wallpaper. About a second later the system recreates your Activity to apply the new theme.
Anything living in a composable’s remember or rememberCoroutineScope dies mid-flow. Mantra’s first onboarding did “render → set wallpaper → write preferences → mark onboarding done” from a composable scope, and the last writes were silently cancelled when the Activity was torn down. Users saw onboarding again on next launch, or worse, ended up half-migrated.
The fixes, in order of importance:
- Application state lives in an
AndroidViewModeland every multi-step write runs inviewModelScope, which survives Activity recreation. - Migrations are idempotent and guarded by a
schema_versionkey in DataStore, because a migration that gets interrupted will run again. - Write the branch-flipping key last.
onboardingDone = truewas being written first; the composable saw it, switched screens, and its scope — with the remaining writes — was disposed. Flip the flag at the end, from the view model.
If your Android app sets wallpapers and has a flaky bug you can’t reproduce on an emulator with dynamic colour off, this is probably it.
WorkManager runs the first period immediately
Mantra rotates backgrounds with a PeriodicWorkRequest. WorkManager’s default is to run the first period immediately on enqueue — which meant the wallpaper the user had just chosen in onboarding was replaced seconds later. setInitialDelay(interval) fixes it. Obvious in hindsight; two evenings in practice.
Rotation also never repeats the last three backgrounds, and photos are included in the “Surprise me” pool once you’ve added any, so a mantra with a kept background stays kept and everything else keeps moving.
Readable text on any photo
Illustrated backgrounds are designed with a quiet zone for text. User photos aren’t. Rather than always slapping a dark gradient behind the words, Mantra measures the luminance mean and standard deviation in the text region and picks a scrim strength from three steps — none for a calm sky, medium for a garden, full for a crowded street — so a photo that’s already legible stays untouched.
Photos come in through the Android Photo Picker (PickMultipleVisualMedia), which needs no storage permission, and are copied into app storage. They never leave the device, and — a rule I’d set before the paywall existed — they’re never paywalled. Putting your own photo behind your own words is the core of the app, not an upsell.
Three redesigns in four days
Mantra went through a “clarity-first” redesign, then a “photos are back” redesign, then a picker simplification, each with a written handoff spec as the source of record. The last one deleted an entire enum value (BackgroundMode.MY_PHOTOS) after it had shipped to test devices. The zero-migration trick: parse stored enum strings with runCatching { valueOf(it) }.getOrDefault(SURPRISE) and old values degrade gracefully instead of crashing on read.
Sixteen languages, RTL included
Every composable string was extracted to strings.xml — about 110 keys, with plurals for the Russian and Arabic quantity sets — and the dynamic “next change at 7:00 tomorrow” sentences build from placeholder resources with DateFormat.getTimeFormat for locale-correct 12/24-hour time. Arabic right-to-left came out clean on the first pass, which is the reward for using start/end instead of left/right from the first commit.
For per-app language (Android 13+), locales_config.xml plus android:localeConfig in the manifest; to test, adb shell cmd locale set-app-locales com.saltserv.mantra --locales nl — settings put system system_locales does not work.
Billing without a middleman
Mantra uses Play Billing directly rather than a subscription SDK: an annual plan with a 7-day trial and a lifetime purchase, no monthly, no per-pack. The one trap: billing-ktx 8.2 needs Kotlin 2.2 metadata, which forced a Kotlin bump. The same class of error as supabase-kt’s version coupling; the message is unhelpful and the fix is a version number.
Mantra is on Google Play. Three mantras and the Still Waters collection are free forever. iOS has no wallpaper API, so an iPhone version would be widget-first — it’s in the design doc, not the roadmap.