Skip to main content

BloomCycle: a period tracker where the data stays on the phone

Share:XLinkedInHN
Cover for BloomCycle: a period tracker where the data stays on the phone

What the app does

BloomCycle is an Android period tracker. You log flow, mood, symptoms, and sleep quality on a given day. It predicts the next cycle from your history, shows a calendar view with the four phases coloured in, and gives you a stats page with correlations between symptoms and cycle day. There is a home screen widget, an ongoing notification during your period (my crude version of a Dynamic Island), a partner mode you can enable if you want to share a redacted view with someone, and a perimenopause tracking flow for cycles that have stopped being predictable.

It went to Google Play Internal Testing at v1.0.1 in March, and the v1.3.0 release fixed 101 bugs and shipped seven new features in one push. That commit message is doing a lot of work.

I want to talk about the boring parts. The parts that matter when the app category is a menstrual tracker in 2026.

Why Kotlin, not Flutter or React Native

I keep hearing people ask me why I didn't just write this in Flutter and get iOS for free. The honest answer is that this app touches too many Android-specific things for a cross-platform layer to be worth the overhead.

Health Connect is the big one. It's Google's replacement for Google Fit, it's an on-device data broker where other apps can read and write with the user's explicit permission per data type, and it's an Android-only surface. There's no Flutter plugin that gives you the same fidelity as calling the Jetpack HealthConnectClient directly. Same story for Glance, the widget framework I use for the home screen tile: it's a Compose dialect for RemoteViews, and it doesn't exist off Android.

The other reason is that Kotlin coroutines plus Room plus Hilt is a well-worn path with a lot of production examples and a lot of Stack Overflow answers when you get stuck at 2am. I didn't want to be the first person to hit a bug in a plugin bridge. Java was never really on the table for a greenfield 2026 app.

The database is encrypted at rest

Room is the standard Android ORM. By default it wraps SQLite, and SQLite by default stores your data in a plain file on the device. On a rooted phone, or with a physical extraction toolkit like Cellebrite, that file is trivially readable.

For a period tracker that is not acceptable. I moved to Room on top of SQLCipher, which is a build of SQLite with AES-256 encryption applied to the database pages before they hit disk. The key comes from Android Keystore, which is the hardware-backed keystore on every reasonably modern Android device. The Keystore alias is created on first launch, the key never leaves the secure element, and I derive the SQLCipher passphrase from a Keystore-wrapped random blob.

The v1.3.0 commit calls this out: "Android Keystore database encryption (migrated from SharedPreferences)." In v1.0.1 the SQLCipher passphrase itself was sitting in a SharedPreferences file under the key db_secret, in the clear. On a rooted device that file is readable, so the encrypted database was one cat away from being decrypted. The v1.3.0 migration reads the legacy passphrase, wraps it with an AES-256-GCM key that lives in the Android Keystore, writes the ciphertext plus the GCM IV to a new preferences file (db_encryption_v2), and removes the legacy entry. The read path checks Keystore-backed storage first and falls through to the legacy prefs only if the new file is empty, so the migration is idempotent across the two versions in the wild.

Local-first, and what that actually means

The phrase "local-first" gets abused. On BloomCycle it means:

Your cycle log, symptoms, moods, notes, and predictions all live in the encrypted Room database on the device. There is no cloud backup of your cycle data. If you uninstall the app your data is gone, and I say that in the onboarding flow so nobody is surprised.

Auto Backup is disabled for the sensitive tables. Android's Auto Backup will upload SharedPreferences and files to Google Drive by default, and I do not want that for a period tracker. backup_rules.xml and data_extraction_rules.xml explicitly exclude the encrypted database and the Keystore-derived preferences. Non-sensitive stuff like theme choice and language is still backed up, because losing your dark-mode preference on device swap is annoying.

Health Connect writes are opt-in. If you enable the Health Connect toggle, the app will write your period start and end dates to Health Connect so other apps you trust can read them. It doesn't read from Health Connect by default. This is the one place where data leaves the app sandbox, and it's under the user's explicit control at the permission dialog.

Analytics are minimal. The only external services the app talks to are RevenueCat (subscription entitlements), Paddle (payment processing for regions where Play Billing is a bad fit), and Supabase for a tiny bit of anonymous crash and feature-flag data. None of these ever see cycle data. RevenueCat gets an anonymous user ID, Paddle gets an email if you paid, Supabase gets crash traces with cycle data stripped out.

The stack, briefly

Jetpack Compose for all UI. Material 3, dark mode as a first-class citizen, no XML layouts anywhere. Compose animations for the calendar swipe and the phase transitions. Lottie for the pet-collection illustrations, which is a small gamification layer I added because the tracker category is aggressively clinical and I wanted the app to feel a little kinder.

Hilt for dependency injection. This ties into WorkManager for the reminder subsystem: a period-approaching notification, a "did you log yesterday" nudge, and a delay alert if your cycle is running long. Hilt-Work bridges the two so WorkManager gets injected dependencies without me hand-wiring a factory.

Glance for the home screen widget. Glance is Compose-shaped code that compiles down to RemoteViews. It's still rough at the edges. Interactivity is limited to lambda callbacks that trip a broadcast receiver, and text sizing is a fight, but the developer experience is dramatically better than writing RemoteViews by hand.

DataStore for preferences. SharedPreferences is deprecated for new code and DataStore's Flow-based reads mesh with Compose recomposition.

WorkManager for the reminder scheduler, biometric auth as an optional app-lock, ZXing for QR code sharing of the read-only partner mode invite, Coil for image loading, and desugar_jdk_libs so I can use java.time on API 26.

What v2 needs

Three things.

First: iOS. The category expects it. Every user email I get eventually asks. I've resisted because HealthKit is not Health Connect, the encryption story on iOS is different (Data Protection API, not Keystore), and I do not want to rebuild the state machine twice. The path forward is probably a Kotlin Multiplatform core with the ViewModels and Room shared, and separate SwiftUI and Compose surfaces. That is a big rewrite and I am not ready.

Second: an optional, end-to-end encrypted backup. Right now if you lose your phone you lose your cycle history. The obvious fix is device-to-device transfer with a QR code and a shared secret, which is a bounded amount of work. The unobvious fix is a cloud backup where the server can never read the data. I've prototyped this with the noise protocol and a Supabase bucket that only ever sees ciphertext, but I have not shipped it because getting the key recovery UX right for a non-technical user is genuinely hard.

Third: a symptom correlation model that runs on-device. The current stats page is descriptive, it tells you how often you logged a headache on cycle day 22 versus cycle day 7. A useful version would be predictive and would flag "your headaches cluster in the luteal phase" or "your energy drops two days before your period on average." A small model that lives in the app, never phones home, and updates as you log more data is the right shape. TensorFlow Lite or the on-device Gemini Nano API are both plausible, and neither breaks the local-first promise as long as the model runs entirely in the app process.

If you're building anything that touches health data in 2026, the default cloud-first architecture is the wrong default. The user's phone is a small, fast, encrypted computer that already has the data, and every byte you don't ship off it is a byte you cannot leak. BloomCycle is my attempt to build in that direction on purpose, and the code is doing what the onboarding screen promises.

Cite as: Saravanan, K. (2026). BloomCycle: a period tracker where the data stays on the phone. Kaushik Saravanan. https://www.kaushik.cv/blog/bloomcycle-kotlin-android