Inspiration

I kept losing the same fight. Sit down to study, and twenty minutes later I'm deep in TikTok with no memory of deciding to open it. Everything I tried to fix it fell into one of two camps. Forest and Flora are honor systems, nothing actually stops you leaving, and once you've killed a few trees the guilt stops working. Opal and One Sec do enforce, but they're styled for calm, general-audience digital wellbeing. Opening them during finals week feels like being handed a meditation cushion when you need someone to take your phone away.

Nobody combined real enforcement with something a student actually wants to open. The mascot came from the same instinct: a capybara is the internet's patron saint of being unbothered, so the level ladder runs Restless Rodent, Calmer Capy, Chillpybara, Zen Master, Guru. You're not just earning points, you're slowly becoming the calm animal.

What it does

Capi blocks the apps and websites eating your study time, on iPhone, iPad, Mac and Android, off one shared codebase.

Pick a duration or a work/break pattern, pick what you don't trust yourself with, start. Those apps are genuinely covered for the session, and blocking holds through breaks so the break timer isn't a loophole. Websites count as blocklist entries too, which matters more than it sounds: on a laptop, Instagram and YouTube aren't apps, they're tabs, so an app-only desktop blocker can't touch the distractions it exists to stop.

Quitting early isn't one tap. A short guided breathing pause first, then a brief cooldown. That mechanic is adopted from One Sec, which has peer-reviewed research behind it from the Max Planck Institute and Heidelberg University showing a large drop in compulsive app opening. Enough friction to interrupt an impulse, not enough to trap someone who genuinely needs out.

Finishing pays. Completed sessions earn XP toward an open-ended level ladder that unlocks cosmetics across five slots plus real functional perks. A daily goal keeps you honest today, a streak keeps you honest this month, and a Streak Shield auto-covers one bad day so nobody quits in week one over a single miss.

Then buddies: a private Weekly Focus League scoped to your own friends that resets Monday, one-tap nudges when a streak looks at risk, async Study Together invites that pay a shared bonus if you both finish, and opt-in Duo Streaks that pause rather than break. No chat, no public leaderboard, no feed to perform on. Your own devices stay in sync too, so starting on your phone means your Mac knows about it.

How we built it

Kotlin Multiplatform with Compose Multiplatform. Around 64k lines of Kotlin across 473 files and 131 test files, split into a shared domain module and a UI module with per-platform expect/actual glue.

Blocking is the one thing that can't be shared, because each OS draws the line somewhere different. Android uses an AccessibilityService watching window state changes plus a foreground service whose notification ticks natively. iOS uses FamilyControls and ManagedSettings shields, which survive a force quit of Capi itself, plus WebContentSettings filtering for sites. macOS has no sanctioned blocking API for a JVM app at all, so it shells out to AppleScript for frontmost process and active tab detection, and ships as a notarized direct download.

Monetization runs on RevenueCat, and it was the best-behaved piece of the stack. purchases-kmp is genuinely multiplatform, so Android and iOS share one Kotlin implementation over Play Billing and StoreKit, with no Swift file, no CocoaPods and no bridge, which is not true of anything else native here. Firebase has no KMP SDK, so the account layer needed three separate implementations: the Android SDK directly, a Swift bridge on iOS, and a hand-rolled REST client plus loopback OAuth on desktop.

Two RevenueCat decisions carried real weight. app_user_id is the Firebase uid instead of RevenueCat's anonymous id, so the desktop app, which has no billing surface to sell through, reads the same entitlement over the REST API keyed to the same account. Buy Pro on your phone and it's live on your laptop, with no second purchase path to build. And cosmetic ownership is derived from RevenueCat's non-subscription transactions, never stored locally, because a local "owned" set is a second source of truth able to disagree with what someone actually paid for.

That instinct runs through the app. Cumulative XP and streak state used to be independently written on every session completion, so a process death mid-write could leave the streak advanced and the XP missing. Both are pure folds over session history, so they're computed on read now. Completion went from four writes to two, and the streak can no longer disagree with the history.

Challenges we ran into

Android OEMs quietly undoing the permission. On Xiaomi's HyperOS the accessibility toggle gets cleared by the OS while backgrounded unless a separate, undocumented Xiaomi Autostart permission is on, and re-enabling accessibility alone doesn't fix it. An app can never re-enable its own accessibility service, that's a security boundary, so there's no silent recovery, only a live health check, a banner and a deep link. Samsung's One UI, tested on real hardware, needed none of it.

A keystroke reading as a visit. Android has no API for the current URL, so site blocking means reading the browser address bar through the accessibility node. That node reports what's being typed as readily as what was navigated to, and Chrome inline-completes "i" to "instagram.com," so one keystroke fired the blocker mid-typing. It has to skip the node while it holds focus. The harder half wasn't technical: reading the address bar needed a flag that contradicted a privacy promise onboarding already made, so the change meant rewriting a user-facing claim, not flipping a boolean.

Silent traps. A new SQLDelight table without its migration file means fresh installs look perfect while every existing database crashes with "no such table." Removing a field from a serializable class crash-looped on app open for anyone with synced history, because a round trip through the current model can never reproduce a field the current model lacks, which is exactly how it passed its own tests.

Accomplishments that we're proud of

Capi is live on the App Store for iPhone and iPad, shipping as a notarized macOS DMG, and in closed testing on Google Play. Real enforcement on three platforms from one codebase, at 1.3.0, with the store catalogue configured.

The entitlement architecture is what I'd defend hardest. Pro follows the account, not the device, so desktop honors a phone purchase without a billing surface of its own, and cosmetics bought once cross devices with no local state to fall stale. Everything fails safe toward free: unknown state reads as not owned, because showing a locked item to someone who owns it costs one Restore tap, while unlocking paid content on a network blip doesn't come back.

I'm also proud of something I deleted. Three strictness tiers were fully built, level gated and celebrated as unlocks, and nothing ever read the value, so all three behaved identically. A "Deep Focus" card promising no way out while letting you out in eleven seconds is a broken promise in the exact place the product makes its core claim.

What we learned

iOS never tells you what the user blocked. Apple hands back opaque ApplicationTokens from its own picker, never app names or icons, so there's no custom app list to build, no custom block screen (a ShieldConfiguration is a static struct, so not even a live countdown), and no way to reject a sixth pick mid-sheet, which means the free-tier cap has to truncate after the sheet closes instead of rejecting the marginal tap like Android does.

The expensive consequence was cross-device. Starting a session on an Android phone mirrored onto an iPad on the same account, showed "session active," and blocked absolutely nothing, because Android's package names and iOS's tokens are structurally incompatible with no possible translation. Apple's tokens aren't even reliably stable across one person's own devices. The fix was to stop trying to send a blocklist at all: each device stores its own last-used blocklist per platform, and a mirrored session blocks that instead. Android to Android is the one case that forwards the original list untouched, since a package name means the same thing on every Android device.

On Android you don't test Android, you test OEMs. Xiaomi's HyperOS clears the accessibility toggle itself while the app is backgrounded unless a separate, undocumented, Xiaomi-only Autostart permission is granted, and re-enabling accessibility afterward isn't enough on its own. Samsung's One UI needed none of that and got by with the standard battery exemption. An emulator would never have shown either result, and since an app can't re-enable its own accessibility service, there's no recovery to code, only detection and a clear ask.

"Blocking" means something different on every platform. On a laptop the distractions are tabs, not apps, so desktop needed websites as a first-class blocklist entry or it would have been blocking nothing anyone actually loses time to. macOS has no usable Family Controls path for a JVM app at all, so it detects the frontmost app and active tab through AppleScript and ships notarized outside the Mac App Store. Firefox has no AppleScript URL support, which is a real gap I shipped rather than hid.

Multiplatform gives you the middle, not the edges. The domain model, the UI and the game economy were genuinely write-once. Every OS boundary was its own build: Firebase has no KMP SDK, so the account layer has three implementations, and no gRPC on desktop means polling where mobile gets live listeners. RevenueCat was the happy exception, one Kotlin implementation over both stores with no Swift and no bridge, which is why monetization took days and blocking took weeks.

What's next for Capi: Focus & Study Timer

Android out of closed testing and onto Play production is first, and the waitlist already has the people to tell.

Push notifications are next, and the reason they were deferred no longer holds. Reaching someone else's device needed a server, and there's a deployed Cloud Functions codebase now, so buddy requests and streak-risk nudges can arrive without the recipient sitting on the Buddies tab.

Then: store products for the ten cosmetics so they can actually be bought, carefully re-adding an annual free trial on iOS behind an on-device check that StoreKit still serves the product, building the advanced stats Pro keeps promising, verifying Windows blocking on real hardware instead of shipping a compiled best effort, and making the cosmetic catalogue server-driven so seasonal drops can mean something other than "shipped in a release."

Built With

Share this project:

Updates

Submission history