How I turned behavioral science into testable Kotlin
Tech Article

How I turned behavioral science into testable Kotlin

July 8, 2026
9 min read

This is part 2 of a two-part series on building Habit Arc. Part 1 was about why I built it around the science of automaticity instead of streaks. This part is about the architecture — and the lessons

AndroidKotlinJetpack Composesoftware architecture

This is part 2 of a two-part series on building Habit Arc. Part 1 was about why I built it around the science of automaticity instead of streaks. This part is about the architecture — and the lessons I learned as a solo developer building a science-backed app.


In Part 1 I made a big claim: that Habit Arc measures automaticity instead of streaks, on top of a real behavioral model — growth curves, lifecycle phases, a validated self-report index. It's a fine-sounding story. It's also the kind of story that's cheap to print in the marketing and quietly fake in the code.

I didn't want to fake it. I wanted the behavioral model to be provably right, free of any framework, and testable on its own. So the biggest architectural decision I made was a boring one: the science lives in a pure-Kotlin core with zero Android dependencies.

The engine is just Kotlin

Everything worth talking about in Habit Arc — the growth math, the phase changes, the recovery logic, the automaticity gate — sits in a core/ package that imports nothing from Android. No Context, no Room, no Compose. Just Kotlin and a handful of data classes.

That isn't tidiness for its own sake. It means the whole behavioral model runs on a plain JVM and tests in milliseconds — no emulator, no instrumentation, no mocking. The habit-strength curve from Part 1 is a pure function:

// Illustrative — the strength calculator in the core engine.
// Score approaches 100 with diminishing returns (Lally et al., 2010).
// k is the growth rate; harder habits grow more slowly.
fun strengthScore(effectiveDays: Double, complexity: Complexity): Double {
    val k = when (complexity) {
        Complexity.SIMPLE   -> 0.06
        Complexity.MODERATE -> 0.045
        Complexity.COMPLEX  -> 0.025
    }
    return 100.0 * (1.0 - exp(-k * effectiveDays))
}

effectiveDays is where Part 1's philosophy turns into arithmetic. A miss subtracts effective-days — but how much depends on the phase, because a young habit is fragile and an old one is tough: a miss costs about 3 effective-days in Formation, about 1.5 in Building, about 0.5 once Established. Recover — do the next rep after a miss — and you get half of that back. "Never miss twice" isn't a slogan glued to the UI. It's a term in the equation.

Because it's all pure functions over plain data, I could write the tests the science deserves:

@Test
fun `recovering the next day restores half the decay`() {
    val missed = calculator.apply(history = listOf(Done, Done, Missed))
    val recovered = calculator.apply(history = listOf(Done, Done, Missed, Done))
    assertThat(recovered.score).isGreaterThan(missed.score)
}

There are around thirty of these small JVM tests — the calculator, the phase gate, the schedule engine, the feedback engine. When I say a missed day won't nuke your progress, there's a test standing guard that fails the moment it stops being true. I built and tested this engine before I drew a single screen, and every time I tore a screen apart afterward, the science underneath never flinched. No decision paid me back more.

The layers around the engine

The rest of the app is an ordinary layered design with the data flowing one way. The rule I refused to bend: the UI never touches a database row directly.

Compose screens
      │  (state down, events up)
      ▼
   ViewModels
      │
      ├──► Core engine (pure Kotlin: strength, phases, SRBAI, schedule)
      │
      └──► Repositories ──► Room  (habits, checks, journals)
                        └─► DataStore (preferences)

Repositories ──► ReminderScheduler ──► AlarmManager └─► BroadcastReceivers ──► back into Repositories

Repositories are the border crossing. They hand out domain models — the same clean types the engine speaks — and do the messy mapping to and from Room entities out of sight. ViewModels stir the engine's pure functions together with repository data and hand the screens a finished, immutable state. The screens themselves are Compose, Material 3, and deliberately dim-witted. DI is Hilt; storage is Room 2.6.1 with an exported schema; preferences live in DataStore. Nothing fancy — the discipline is in which way the arrows point, not in the length of the dependency list.

Offline-first, and literally unable to phone home

In Part 1 I said your data never leaves your phone, and that I'd made that impossible to break. Here's the trick, and it's almost dumb in its simplicity.

The AndroidManifest.xml asks for exactly two permissions:

<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />

No INTERNET permission. None. The app couldn't ship your habits off to a server if I begged it to — the operating system won't hand it a socket. Privacy is easy to promise in a paragraph and easy to lose one analytics SDK at a time. Delete the internet permission and the promise becomes a fact of physics. No analytics, no crash reporting, no cloud, no account wall.

That one absence shapes everything downstream. Room is the single source of truth. Every screen watches the database through a Kotlin Flow; a write lands in Room, Room emits, Compose redraws. There's no server cache to reconcile, no optimistic update to roll back, no sync conflict at 2 a.m. — because there is no server. The hardest problem in most apps simply never checks in.

The one door data can walk out of, it walks out on your terms: a manual JSON export through the Storage Access Framework, into a file you pick, and back in the same way. (A detail I'm fond of: the backup file carries its own schema version, kept separate from the Room database version, so the saved format and the export format can grow up independently.)

The part that was actually hard: modeling "a day"

The behavioral science was the fun part. The genuinely fiddly part — the one that ate the most evenings — was deciding what a habit even is on a given day.

Habit Arc runs on a slot-based model. A habit's schedule (daily; weekly on the weekdays you pick; monthly on the dates you pick, including a real "last day of the month" that knows 28 from 31) produces a set of due slots, and every due slot becomes one card on your Today screen. A Build habit can hold up to eight reminder slots a day. A Reduce habit is stored the same way but folds down, at runtime, to a single end-of-day reflection — because pestering someone eight times about a habit they're trying to quit is precisely the sort of thing this app won't do.

Every check, journal note, and duration is filed under (habitId, date, slotId); the weekly automaticity answer under (habitId, date). Those keys are dull and load-bearing at the same time — get one wrong and a journal note clings to the wrong rep, or an undo sweeps away more than it should.

Two smaller calls I'm glad I made:

  • Write first, ask questions later. Tap Done or Missed and the status is written that instant — the app feels quick. Anything extra (a duration, the weekly automaticity prompt, a journal line) is gathered afterward, never blocking the tap. Taps are debounced about 300ms so a clumsy double-tap doesn't make a mess.

  • No backdating. You can look back 30 days, but you can't reach into last Tuesday and mark it Done. It's tempting — it would make the charts prettier — but a habit tracker you can edit into a success story isn't tracking anything at all. An honest history was worth more than a flattering one.

Reminders without the scary permission

Reminders ride on AlarmManager, one per slot, with BroadcastReceivers that fire the notifications and — just as important — put every alarm back after a reboot, a clock change, a timezone hop, or an app update. The Done and Missed buttons on the notification run the exact same wording and the exact same write path as the ones inside the app, so the language never drifts depending on where you tap.

I deliberately skipped exact alarms. SCHEDULE_EXACT_ALARM is an intrusive permission that (rightly) puts users and reviewers on edge. A habit nudge doesn't need to land at 8:00:00.000; a little after eight is fine. setAndAllowWhileIdle delivers reliably and easy on the battery, without me having to ask for a permission that would quietly contradict the calm, respectful thing I was trying to build.

Lessons learned

Build the model before the interface. Putting the science in a framework-free core and testing it to death before I drew a single screen is the move I'd make again every time. The UI churned week to week; the engine barely budged. "Separation of concerns" stops being a lecture-hall phrase the first time you gut an entire screen and never once worry about the math underneath.

Documentation rots faster than you'd believe — doubly so on an AI-assisted codebase. I lean on AI tools hard, and they'll happily call a shipped feature "planned," or a red build "green." So I adopted one blunt rule — code is the source of truth for behavior — and ran an audit that sorted every doc into "canonical" (checked against shipped code) or "historical" (kept for the story, not the truth). It turned up real fibs: docs swearing onboarding wasn't built when it was, design pieces filed under "someday" that already existed. If you build with AI, treat your own docs as a witness who needs cross-examining.

Being honest about what isn't done is itself a feature. The code stashes a couple of appearance settings — theme mode, dynamic color — that aren't wired to anything yet; the app is light-only for now. I could have papered over that. Instead the docs and the store copy simply don't claim dark mode works, and there's a plain "do not mention yet" list for everything unfinished. For an app whose whole pitch is that you can trust it, a little over-claiming would cost more than the missing feature ever could.

Names are decisions, not decoration. Two renames changed more than I expected. "Progress" became "Growth" — progress is a cold percentage; growth is something that puts down roots. And the app itself went from "Habit Tracker" to "Habit Arc," because the arc is the entire point: habits dip, recover, and climb over time. Getting the words right changed how the thing felt before I touched a pixel.

Let the app show its work. There's a "Science & Engine" page in Settings that spells out, in plain words, how the Growth Score, automaticity, and recovery actually work. I didn't want the machine grading your progress to be a black box. If I'm going to ask you to trust a number, the least I owe you is where it came from.

Where it is, and an ask

Habit Arc is a native Android app — Kotlin, Jetpack Compose, Room, Hilt — built by one person, local-first, and sitting in a closed beta on Google Play. It's free, and it needs real people using it for a couple of weeks to reach the next stage.

If a calm, private, science-backed habit tracker that refuses to shame you sounds like your kind of thing, try it here. And if you build Android apps yourself, I'm always up for talking architecture — reach out.


Building Habit Arc — Part 2 of 2. Part 1: why I built it around the science of automaticity instead of streaks.