I doomscroll. A lot. The honest version: I'd unlock my phone to check the time and resurface forty minutes later, having read nothing I'll remember. I tried every "minimalist launcher" out there, the kind that hides apps behind a text list. They work for about a week, until your thumb learns the new muscle memory and you're back. Hiding isn't blocking. "Hidden until I cave" is just a speed bump.
So I built Tempo: a home-screen replacement that does three quiet things, tell the time, find an app, show notifications, on a calm washi-paper canvas. And it has one feature none of the others do. When you hide an app, it's gone for 10 days, with no take-backs, and the block survives uninstalling the app.
This is the build log: what it feels like, why the blockade is the whole point, and the engineering that makes it stick. It's MIT-licensed and the repo is linked at the bottom.
What it feels like
There are exactly three screens, reached from a floating dock pill:
- Home (ホーム). A faint sumi-e ensō ring behind a large mincho clock, the date in vertical Reiwa-era kanji (令和八年・六月十八日), a spoken-style reading, and a single vermillion 静 ("stillness") seal. That's it. No icons, no widgets, no folders.
- Search (検索). A live-filtered list of every installed app. No grid to graze on; you have to know what you came for and type it.
- Notifications (通知). Your real notifications, tap to open, swipe to dismiss.
The whole thing is in Japanese, a language I can't actually read. That's deliberate. The friction is the feature: a phone that's slightly harder to use mindlessly is a phone you use less. The aesthetic isn't decoration, it's a speed bump you chose on purpose.
There's a light "Paper" theme (washi cream) and a dark "Sumi" theme (warm charcoal, not the usual true-black void). Both run a faint paper-grain pass so it reads like a sheet, not a screen.
The one idea that matters: a block you can't take back
Every other minimalist launcher treats hiding as a toggle. Tempo treats it as a commitment device. Hide an app and you confirm a 10-day sentence. While it's blocked:
- it's gone from Search,
- its notifications are suppressed system-wide (via a
NotificationListenerService), and - it cannot be un-hidden until the 10 days elapse. Not by tapping, not by clearing data, not by uninstalling Tempo and reinstalling it.
That last clause is the hard part, and it's where most "strict mode" features quietly cheat. If your block lives in app storage, it dies the moment the user reinstalls, a 15-second escape hatch. A real commitment device has to close that door.
How the blockade actually sticks
The ledger is dead simple: a map of package -> unlockAt (absolute epoch-millis). Presence means hidden; un-hiding is refused until now() passes unlockAt. The interesting part is defending it against the two obvious cheats.
Cheat 1: uninstall and reinstall to wipe the ledger. App-private storage is erased on uninstall, so the ledger is also mirrored to a dotfile in shared storage (Documents/.tempo_keep.json) through All-files access. On a fresh install the two ledgers are reconciled, and per package the later unlockAt always wins:
private fun mergeByMax(a: Map<String, Long>?, b: Map<String, Long>?): Map<String, Long> {
val out = HashMap<String, Long>(a ?: emptyMap())
b?.forEach { (pkg, unlockAt) -> out[pkg] = maxOf(out[pkg] ?: 0L, unlockAt) }
return out
}
Reinstalling can never shorten a block; at worst the merge is a no-op. The same rule applies when you start a new block: it maxOfs against any existing one, so the system can extend but never reduce.
Cheat 2: wind the system clock back. If "now" is just System.currentTimeMillis(), you set the date to next month and the block evaporates. So Tempo keeps a monotonic high-water mark and reads time as the max of the two:
/** Guarded "now": never earlier than the highest time we've previously observed. */
fun now(): Long = maxOf(System.currentTimeMillis(), lastSeen)
lastSeen is persisted in the same ledger and bumped on every observation. Roll the clock back and now() simply doesn't move; roll it forward and you've only sped up your own sentence.
One honest caveat I put right in the source: this is best-effort, not tamper-proof. A determined user can still delete the shared dotfile or factory-reset. Truly unbypassable enforcement needs Device Owner provisioning, which is way out of scope for a launcher you install for yourself. The bar I'm aiming for isn't "defeats a motivated attacker," it's "defeats me at 11pm with low willpower," and for that, closing the reinstall and clock-rollback doors is enough.
The whole thing is a single BlockadeRepository: seed synchronously at startup so the first Search frame already excludes blocked apps (no flash of the thing you're trying not to see), then reconcile against the durable mirror off the main thread.
The rest of the stack
It's deliberately small. No DI framework, no third-party UI libraries, just AndroidX.
-
100% Jetpack Compose, drawn edge-to-edge. One
LauncherViewModelover three repositories (apps, theme, blockade). State inDataStore. -
LauncherAppsfor a live, profile-aware app inventory (work-profile apps included), kept current with its callback rather than a one-shotPackageManagerquery. -
Theming is a tiny
TempoColorsdata class behind aCompositionLocal, with the background painted as a top-anchored radial wash plus a cached noise tile, multiplied in Paper, screened in Sumi so the grain catches light instead of muddying the charcoal.
Drawing my own icons
A drawer full of the OS's colorful, mismatched icons wrecks the calm. So Tempo doesn't use them. It ships an internal library of monochrome line glyphs, hand-written 24×24 SVG paths stroked with round caps via Compose's PathParser, and resolves each installed app to one by function in four passes:
- exact package id (the top ~140 apps, so
com.spotify.musicmaps to music), - a keyword in the package id (
...dialermaps to phone), - a keyword in the display name, English or Japanese (
天気/ "weather" maps to a cloud), - the declared
ApplicationInfocategory.
Anything unmatched falls back to a washi monogram tile of its first character, so even the long tail looks intentional. The trade-off is obvious: a niche app won't get a bespoke glyph. But the drawer reads as one coherent sheet instead of a ransom note, and I deleted the entire icon-bitmap decode and cache path in the process.
Getting it to 4.5 MB
The first release was 36 MB. Almost all of it was fonts: two full Shippori Mincho CJK weights at roughly 8.6 MB each, plus two Zen Kaku Gothic weights. A launcher does not need 20,000 kanji.
I subset them with pyftsubset to Latin, kana, the 2,136 jōyō kanji, and every glyph the UI literally uses, with the system Noto font as graceful fallback for anything rarer:
pyftsubset shippori_mincho_regular.ttf \
--output-file=out.ttf \
--unicodes=U+0020-007E,U+3000-30FF,U+FF00-FFEF \
--text-file=joyo+ui-glyphs.txt \
--layout-features='*' --no-hinting
That alone took fonts from 21 MB to 3.9 MB. Turning on R8 (the release build had optimization disabled) collapsed the code, resource shrinking cleaned up the rest, and an ABI filter dropped the useless x86 stubs. Final signed APK: roughly 4.5 MB, an 88% cut, with no visible change to the design.
Shipping it
Releases are a git tag away. A GitHub Actions workflow triggers on v*, decodes the signing keystore from a base64 secret, derives the version name from the tag, builds the signed APK, and publishes a Release with the artifact attached:
git tag v0.0.4 && git push origin v0.0.4
# CI builds Tempo-v0.0.4.apk, signed, attached to the GitHub Release
The keystore never lives in the repo; signing config reads from local.properties locally or env-injected secrets in CI.
What I'd flag if you build something similar
- All-files access is a heavy permission to ask for, and rightly scrutinized. I only use it to write one dotfile, and the app degrades gracefully without it (the block still works, it just won't survive a reinstall). Be honest with users about why you need it.
- A user-hostile feature is a design tightrope. "You literally cannot undo this for 10 days" is the point, but it means the confirmation copy has to be unambiguous and the countdown always visible. If it ever feels like a bug instead of a choice, you've lost.
- Subsetting fonts is the highest-leverage size win for any app bundling CJK or icon fonts, and almost nobody does it. Start there before micro-optimizing dex.
Try it, take it apart
Tempo is open source (MIT), built with Kotlin and Jetpack Compose, minSdk 35. Grab the latest signed APK from the releases page, or clone it and read the BlockadeRepository, the most interesting 160 lines in the project.
- Repo: https://github.com/eddiegulay/tempo
- Releases (download the APK): https://github.com/eddiegulay/tempo/releases
If you've also tried and failed to out-discipline your own thumb, I'd genuinely like to hear whether a block you can't take back works for you the way it's working for me.

Top comments (0)