Focus: a text-only, black and white minimalist launcher for Android. App timers that lock distracting apps, a weekly screen time review, no internet permission.
See the codeReclaim your time. Spend it touching some grass.
Focus is a minimalist launcher for Android: a text-only home screen built to be looked at as little as possible. No icons, no colour: black and white, the time, up to five apps you chose, and an honest picture of where the day went. It locks social apps and games when their daily time is up, and once a week it shows you the week you actually had.
Website · Download the APK (1.3 MB, Android 8.0+) · no ads, no account, no internet permission
Built with Kotlin and Jetpack Compose, without the Material library, in about 6,500 lines.
Home screen (page 1)
App drawer (page 2)
App timers
Weekly review
Android's own label cannot be used as is. Its CATEGORY_SOCIAL officially means "messaging,
communication, email, or social network apps" (the Play Store files its Social, Communication and
Dating categories under it), so Gmail, Chrome and WhatsApp carry the same label as Instagram.
AppRepository.categorize therefore works in this order:
Being wrong in the strict direction (locking mail) costs more trust than being wrong in the lax one (missing a niche social app), so the classifier never guesses towards a limit. Every automatic decision is visible in "Limited apps" and can be overridden per app.
| Switch | Needed for |
|---|---|
| Default launcher | Being the home screen. |
| Usage access | All screen-time numbers: day bar, timers, weekly review. |
| Accessibility service ("Focus app timers") | Locking an app while you are in it. It only listens for window changes to learn the name of the app in front; canRetrieveWindowContent is false, so it cannot read the screen. |
| Notifications (optional) | The weekly review reminder. |
| Calendar (optional) | The calendar section. |
Without the accessibility service, timers still work at launch time: a spent app opened from Focus shows the wall instead. With it, the wall also comes up mid-session.
The app declares no INTERNET permission. Nothing leaves the phone.
Requirements: JDK 17–21 and the Android SDK with platform 36.
./gradlew :app:assembleDebug # debuggable build
./gradlew :app:assembleRelease # R8-optimised build (~1.3 MB), signed with the debug key
./gradlew :app:testDebugUnitTest # usage state machine + emoji stripping
adb install -r app/build/outputs/apk/release/app-release.apk
adb shell cmd package compile -m speed-profile -f com.focus.launcher # optional: precompile right away
Use the release build for daily use. Compose is markedly slower in debuggable builds; swipes
and scrolling stutter there in a way they do not in release. profileinstaller is included so the
baseline profiles inside the Compose libraries are applied to sideloaded builds too.
Two machine-specific files:
local.properties → sdk.dir (not for version control).gradle.properties → org.gradle.java.home is pinned to Homebrew's JDK 21, because Gradle 8.14
cannot run on JDK 25. Remove or edit that line on another machine.Library versions are deliberately one step behind the newest: Compose 1.12 / core 1.19 /
lifecycle 2.11 require compileSdk 37 and AGP 9.1+. See gradle/libs.versions.toml.
Debug builds additionally export ReviewActivity and BlockActivity
(app/src/debug/AndroidManifest.xml) so they can be opened from adb:
adb shell am start -n com.focus.launcher/.SettingsActivity --es route timers
adb shell am start -n com.focus.launcher/.ReviewActivity
adb shell am start -n com.focus.launcher/.BlockActivity --es package com.instagram.android --el used 1860000 --ei limit 30 --ez preview true
Live at https://how2me.me/focusapp/ : a black and white, script-free landing page with the
APK download. Source in site/src, generated output in site/public (git-ignored; it contains
the APK).
./gradlew :app:assembleDist && site/deploy.sh # build signed APK, build site, upload, verify
deploy.sh re-downloads the APK afterwards and compares its SHA-256 with the local build, so a
bad upload fails loudly. The page shows that same checksum.
<title>, description and an eyebrow inside the <h1> carry the phrase
people search for ("minimalist launcher for Android"); the visible headline stays the owner's
own line. The build derives JSON-LD (MobileApplication + FAQPage) from the page itself,
writes sitemap.xml, and a robots.txt that is served at the domain root (it restricts
nothing; it only names the sitemap). deploy.sh pings IndexNow (Bing, Yandex, Seznam, Naver)
after every deploy. Google takes no pings: it needs Search Console. Add the property
https://how2me.me/focusapp/ (URL prefix), choose the HTML-file method, drop the downloaded
google….html into site/src/, run site/deploy.sh, press Verify, then submit sitemap.xml.
None of this substitutes for other sites linking here; nothing links to the page yet, not even
the how2me.me homepage./var/www/focusapp, deliberately outside
the main how2me.me root so redeploying that site cannot wipe them. The rules are in
site/nginx-focusapp.conf, installed as /etc/nginx/snippets/focusapp.conf and pulled into
the how2me.me server block by a single include line (a backup of the config from before that
edit is at /etc/nginx/how2me.bak-*). Strict CSP (default-src 'none', no scripts, no inline
styles), .apk served as application/vnd.android.package-archive with
Content-Disposition: attachment because nginx 1.18 does not know the type.debug; release (optimized, signed with the debug key, for the developer's own phone); and
dist (the same optimized build signed with the real key, the only one that may be published).
The key is ~/.android/keystores/focus-release.jks; its location and password are in
keystore.properties (git-ignored). Back both up. If they are lost, no update can ever be
installed over a published build. Android will not install a dist APK over a debug/release
one or vice versa (different signatures): switching a phone between them means uninstalling
first, which resets Focus's settings.A launcher is the one app that is always alive, so what it costs while nothing is happening
matters more than anything else. Measured on a OnePlus (Android 16), release build compiled with
speed-profile:
graphicsLayer,
Canvas, the press indication) so they repaint without recomposing.UsageRepository.DayAccumulator feeds each system event to
a long-lived ForegroundTracker exactly once; a refresh only asks the OS for events since the
previous one (leaving the last 1.5 s to settle, because the OS writes its log from another
thread). Re-reading the whole day on every return home, as the first version did, meant
iterating tens of thousands of events a hundred times a day. When nothing changed, the very same
DayUsage object is handed back, so nothing recomposes either.lastUpdateTime; a label is only loaded for
apps that are new or updated, and the cache is tied to the locale. A single install/update
refreshes just that package instead of rescanning everything.ContentObserver that only flips a flag), an event in it ends, or ten minutes pass.makeCustomAnimation) are ignored
for task-level transitions such as a launcher opening an app; only the built-in scale-up and
clip-reveal types are honoured. The system's own animation is one setting away (Appearance →
Opening apps). Each launch logs how long the
launcher took to hand over (adb logcat -s FocusLaunch), to compare with the system's
Displayed … +NNNms, which is the opened app's own start-up time.CompositingStrategy.ModulateAlpha, not through a full-screen off-screen
buffer per page per frame.speed-profile, not speed. Compiling every method is marginally faster but
maps several MB more code; compiling the hot paths named in the Compose baseline profiles
(profileinstaller) gets nearly all of the speed for less memory, and is what Android does by
itself for store-installed apps. Caches are dropped on TRIM_MEMORY_BACKGROUND, and
deliberately not on UI_HIDDEN, which fires on every app launch and would defeat them.To check for yourself:
adb shell dumpsys meminfo com.focus.launcher | grep -E "Java Heap:|Native Heap:|Code:|Graphics:|TOTAL PSS:"
adb shell dumpsys gfxinfo com.focus.launcher | grep "Total frames rendered" # run twice, 10 s apart, screen idle
app/src/main/java/com/focus/launcher/
FocusApp.kt, Graph.kt Application + hand-rolled service locator (one process, shared state)
MainActivity.kt Pager: home ⇄ drawer, lifecycle refresh, Home-button handling
SettingsActivity.kt "Focus Settings" (also the LAUNCHER entry)
ReviewActivity.kt Today / weekly review
BlockActivity.kt The "Time's up" wall
data/
Settings.kt, SettingsStore.kt Preferences as one immutable data class in a StateFlow
AppRepository.kt LauncherApps scan, labels, categories, disk cache for instant cold start
UsageRepository.kt Usage events → per-app, per-hour screen time; per-day JSON cache
LimitManager.kt Which apps are limited, continue/bypass passes, tally for the review
WeekSummary.kt Aggregation behind the weekly review
ForegroundTracker.kt Pure state machine: usage events in, foreground intervals out (unit tested)
CalendarRepository.kt Calendar list, one-calendar agenda, emoji stripping (unit tested)
AppState.kt
service/
FocusAccessibilityService.kt Mid-session enforcement
WeeklyReview.kt Alarm, notification, "review is due" bookkeeping
ui/
theme/ Two palettes (black, white), press-fade indication, edge-to-edge helpers
components/ Text, rows, switch, buttons, dialogs. No Material: nothing brings in colour.
home/ drawer/ block/ review/ settings/
UsageStatsManager.queryEvents gives RESUMED / PAUSED / STOPPED events per activity. Android pairs
them by activity instance, but the instance id is hidden from apps, and apps like Instagram stack
several instances of the same activity class. Tracking by class name loses time (a late STOPPED of
an old instance closes the new one; on a real day this lost 24 of Instagram's 75 minutes).
So ForegroundTracker (fed by UsageRepository.compute) keeps, per package, a counter of resumed-but-not-yet-paused
activities, ignores STOPPED, and uses "another package resumed and no PAUSED followed within 3 s"
as the safety net for crashes and split-screen. Checked against the system's instance-aware totals
for 40 apps over a real day, every app was within 10 seconds (5:28:50 vs 5:29:20 overall).
Two things the numbers deliberately leave out, and one they cannot see. Time on a home screen is
not counted, but only real launchers are excluded: the Settings app also declares a HOME
activity (FallbackHome, priority -1000, shown while the phone boots), and mistaking it for a
launcher once made all time spent in Settings vanish. And usage inside an Android Work profile is
invisible to a normal app (UsageStatsManager only covers the calling user), so Focus's total can
be a little lower than Digital Wellbeing's on phones that have one.
Note that "today" means since local midnight. Android's own daily bucket does not start at
midnight, so Digital Wellbeing-style totals from dumpsys usagestats can differ for apps used the
previous evening.
The OS keeps raw events for only about a week, so each finished day is stored as
files/usage/YYYY-MM-DD.json. That is what lets the review look further back. Bump
DayUsage.CACHE_VERSION whenever the computation changes.
2 commits
Kotlin
87.6%
HTML
5.7%
CSS
4.6%
Shell
2.1%
Focus: a text-only, black and white minimalist launcher for Android. App timers that lock distracting apps, a weekly screen time review, no internet permission.
See the codeReclaim your time. Spend it touching some grass.
Focus is a minimalist launcher for Android: a text-only home screen built to be looked at as little as possible. No icons, no colour: black and white, the time, up to five apps you chose, and an honest picture of where the day went. It locks social apps and games when their daily time is up, and once a week it shows you the week you actually had.
Website · Download the APK (1.3 MB, Android 8.0+) · no ads, no account, no internet permission
Built with Kotlin and Jetpack Compose, without the Material library, in about 6,500 lines.
Home screen (page 1)
App drawer (page 2)
App timers
Weekly review
Android's own label cannot be used as is. Its CATEGORY_SOCIAL officially means "messaging,
communication, email, or social network apps" (the Play Store files its Social, Communication and
Dating categories under it), so Gmail, Chrome and WhatsApp carry the same label as Instagram.
AppRepository.categorize therefore works in this order:
Being wrong in the strict direction (locking mail) costs more trust than being wrong in the lax one (missing a niche social app), so the classifier never guesses towards a limit. Every automatic decision is visible in "Limited apps" and can be overridden per app.
| Switch | Needed for |
|---|---|
| Default launcher | Being the home screen. |
| Usage access | All screen-time numbers: day bar, timers, weekly review. |
| Accessibility service ("Focus app timers") | Locking an app while you are in it. It only listens for window changes to learn the name of the app in front; canRetrieveWindowContent is false, so it cannot read the screen. |
| Notifications (optional) | The weekly review reminder. |
| Calendar (optional) | The calendar section. |
Without the accessibility service, timers still work at launch time: a spent app opened from Focus shows the wall instead. With it, the wall also comes up mid-session.
The app declares no INTERNET permission. Nothing leaves the phone.
Requirements: JDK 17–21 and the Android SDK with platform 36.
./gradlew :app:assembleDebug # debuggable build
./gradlew :app:assembleRelease # R8-optimised build (~1.3 MB), signed with the debug key
./gradlew :app:testDebugUnitTest # usage state machine + emoji stripping
adb install -r app/build/outputs/apk/release/app-release.apk
adb shell cmd package compile -m speed-profile -f com.focus.launcher # optional: precompile right away
Use the release build for daily use. Compose is markedly slower in debuggable builds; swipes
and scrolling stutter there in a way they do not in release. profileinstaller is included so the
baseline profiles inside the Compose libraries are applied to sideloaded builds too.
Two machine-specific files:
local.properties → sdk.dir (not for version control).gradle.properties → org.gradle.java.home is pinned to Homebrew's JDK 21, because Gradle 8.14
cannot run on JDK 25. Remove or edit that line on another machine.Library versions are deliberately one step behind the newest: Compose 1.12 / core 1.19 /
lifecycle 2.11 require compileSdk 37 and AGP 9.1+. See gradle/libs.versions.toml.
Debug builds additionally export ReviewActivity and BlockActivity
(app/src/debug/AndroidManifest.xml) so they can be opened from adb:
adb shell am start -n com.focus.launcher/.SettingsActivity --es route timers
adb shell am start -n com.focus.launcher/.ReviewActivity
adb shell am start -n com.focus.launcher/.BlockActivity --es package com.instagram.android --el used 1860000 --ei limit 30 --ez preview true
Live at https://how2me.me/focusapp/ : a black and white, script-free landing page with the
APK download. Source in site/src, generated output in site/public (git-ignored; it contains
the APK).
./gradlew :app:assembleDist && site/deploy.sh # build signed APK, build site, upload, verify
deploy.sh re-downloads the APK afterwards and compares its SHA-256 with the local build, so a
bad upload fails loudly. The page shows that same checksum.
<title>, description and an eyebrow inside the <h1> carry the phrase
people search for ("minimalist launcher for Android"); the visible headline stays the owner's
own line. The build derives JSON-LD (MobileApplication + FAQPage) from the page itself,
writes sitemap.xml, and a robots.txt that is served at the domain root (it restricts
nothing; it only names the sitemap). deploy.sh pings IndexNow (Bing, Yandex, Seznam, Naver)
after every deploy. Google takes no pings: it needs Search Console. Add the property
https://how2me.me/focusapp/ (URL prefix), choose the HTML-file method, drop the downloaded
google….html into site/src/, run site/deploy.sh, press Verify, then submit sitemap.xml.
None of this substitutes for other sites linking here; nothing links to the page yet, not even
the how2me.me homepage./var/www/focusapp, deliberately outside
the main how2me.me root so redeploying that site cannot wipe them. The rules are in
site/nginx-focusapp.conf, installed as /etc/nginx/snippets/focusapp.conf and pulled into
the how2me.me server block by a single include line (a backup of the config from before that
edit is at /etc/nginx/how2me.bak-*). Strict CSP (default-src 'none', no scripts, no inline
styles), .apk served as application/vnd.android.package-archive with
Content-Disposition: attachment because nginx 1.18 does not know the type.debug; release (optimized, signed with the debug key, for the developer's own phone); and
dist (the same optimized build signed with the real key, the only one that may be published).
The key is ~/.android/keystores/focus-release.jks; its location and password are in
keystore.properties (git-ignored). Back both up. If they are lost, no update can ever be
installed over a published build. Android will not install a dist APK over a debug/release
one or vice versa (different signatures): switching a phone between them means uninstalling
first, which resets Focus's settings.A launcher is the one app that is always alive, so what it costs while nothing is happening
matters more than anything else. Measured on a OnePlus (Android 16), release build compiled with
speed-profile:
graphicsLayer,
Canvas, the press indication) so they repaint without recomposing.UsageRepository.DayAccumulator feeds each system event to
a long-lived ForegroundTracker exactly once; a refresh only asks the OS for events since the
previous one (leaving the last 1.5 s to settle, because the OS writes its log from another
thread). Re-reading the whole day on every return home, as the first version did, meant
iterating tens of thousands of events a hundred times a day. When nothing changed, the very same
DayUsage object is handed back, so nothing recomposes either.lastUpdateTime; a label is only loaded for
apps that are new or updated, and the cache is tied to the locale. A single install/update
refreshes just that package instead of rescanning everything.ContentObserver that only flips a flag), an event in it ends, or ten minutes pass.makeCustomAnimation) are ignored
for task-level transitions such as a launcher opening an app; only the built-in scale-up and
clip-reveal types are honoured. The system's own animation is one setting away (Appearance →
Opening apps). Each launch logs how long the
launcher took to hand over (adb logcat -s FocusLaunch), to compare with the system's
Displayed … +NNNms, which is the opened app's own start-up time.CompositingStrategy.ModulateAlpha, not through a full-screen off-screen
buffer per page per frame.speed-profile, not speed. Compiling every method is marginally faster but
maps several MB more code; compiling the hot paths named in the Compose baseline profiles
(profileinstaller) gets nearly all of the speed for less memory, and is what Android does by
itself for store-installed apps. Caches are dropped on TRIM_MEMORY_BACKGROUND, and
deliberately not on UI_HIDDEN, which fires on every app launch and would defeat them.To check for yourself:
adb shell dumpsys meminfo com.focus.launcher | grep -E "Java Heap:|Native Heap:|Code:|Graphics:|TOTAL PSS:"
adb shell dumpsys gfxinfo com.focus.launcher | grep "Total frames rendered" # run twice, 10 s apart, screen idle
app/src/main/java/com/focus/launcher/
FocusApp.kt, Graph.kt Application + hand-rolled service locator (one process, shared state)
MainActivity.kt Pager: home ⇄ drawer, lifecycle refresh, Home-button handling
SettingsActivity.kt "Focus Settings" (also the LAUNCHER entry)
ReviewActivity.kt Today / weekly review
BlockActivity.kt The "Time's up" wall
data/
Settings.kt, SettingsStore.kt Preferences as one immutable data class in a StateFlow
AppRepository.kt LauncherApps scan, labels, categories, disk cache for instant cold start
UsageRepository.kt Usage events → per-app, per-hour screen time; per-day JSON cache
LimitManager.kt Which apps are limited, continue/bypass passes, tally for the review
WeekSummary.kt Aggregation behind the weekly review
ForegroundTracker.kt Pure state machine: usage events in, foreground intervals out (unit tested)
CalendarRepository.kt Calendar list, one-calendar agenda, emoji stripping (unit tested)
AppState.kt
service/
FocusAccessibilityService.kt Mid-session enforcement
WeeklyReview.kt Alarm, notification, "review is due" bookkeeping
ui/
theme/ Two palettes (black, white), press-fade indication, edge-to-edge helpers
components/ Text, rows, switch, buttons, dialogs. No Material: nothing brings in colour.
home/ drawer/ block/ review/ settings/
UsageStatsManager.queryEvents gives RESUMED / PAUSED / STOPPED events per activity. Android pairs
them by activity instance, but the instance id is hidden from apps, and apps like Instagram stack
several instances of the same activity class. Tracking by class name loses time (a late STOPPED of
an old instance closes the new one; on a real day this lost 24 of Instagram's 75 minutes).
So ForegroundTracker (fed by UsageRepository.compute) keeps, per package, a counter of resumed-but-not-yet-paused
activities, ignores STOPPED, and uses "another package resumed and no PAUSED followed within 3 s"
as the safety net for crashes and split-screen. Checked against the system's instance-aware totals
for 40 apps over a real day, every app was within 10 seconds (5:28:50 vs 5:29:20 overall).
Two things the numbers deliberately leave out, and one they cannot see. Time on a home screen is
not counted, but only real launchers are excluded: the Settings app also declares a HOME
activity (FallbackHome, priority -1000, shown while the phone boots), and mistaking it for a
launcher once made all time spent in Settings vanish. And usage inside an Android Work profile is
invisible to a normal app (UsageStatsManager only covers the calling user), so Focus's total can
be a little lower than Digital Wellbeing's on phones that have one.
Note that "today" means since local midnight. Android's own daily bucket does not start at
midnight, so Digital Wellbeing-style totals from dumpsys usagestats can differ for apps used the
previous evening.
The OS keeps raw events for only about a week, so each finished day is stored as
files/usage/YYYY-MM-DD.json. That is what lets the review look further back. Bump
DayUsage.CACHE_VERSION whenever the computation changes.
2 commits
Kotlin
87.6%
HTML
5.7%
CSS
4.6%
Shell
2.1%