An offline menstrual cycle tracker for Android. One person, one phone, no account, no network.
Built because the alternatives either upload your cycle to someone else's server or make confident
predictions from data they never had. Luna does neither: there is no INTERNET permission and
there never will be, and it refuses to state a number it has not earned.
Personal project, built for one user. It is not on any app store, has no support, and is not a medical device. Nothing in it is medical advice.
- Cycle, phase and day, derived from logged bleeding — never from a fabricated anchor
- A predicted window for the next period, not a single date, that narrows as real cycles accumulate
- A ten-second log screen: bleeding, flow, seven symptoms on anchored scales, six confounder tags
- A history calendar where any past day can be corrected, with estimated days visibly marked
- Phase guidance — what is typical, kept strictly separate from what your own logs show
- Health flags — a late period, repeated long or short cycles, bleeding between periods
- A reminder you can answer without opening the app — Bleeding / No bleeding in one tap — and a heads-up a configurable few days before the window opens
- A plain-text summary to take to an appointment, with observed and estimated never conflated
- A home-screen widget that works even when the OS kills background work
- Encrypted backup to a file you control (PBKDF2 + AES-GCM)
- Biometric app lock, and the app switcher shows a blank card instead of your cycle — a switch, private by default, because blocking screenshots is a real cost to some people
- Screen-reader support throughout; decoration is hidden from it rather than announced
Release APK is 2.41 MB.
These are not aspirations; they are enforced in code and in tests.
1. The engine owns every number. A language model may parse input or phrase output. It never produces a value.
2. Absent is not zero. A day you did not log and a day you logged as zero stay distinguishable everywhere — in the schema, the statistics and the UI. Averaging a blank as a floor value is the single easiest way to quietly corrupt a health record.
3. Confidence is earned. The app records what it predicted, then grades itself when the period arrives. Until three cycles have been predicted and observed, it reports no accuracy figure at all — not a low one, none.
4. Adherence is the binding constraint. Analytics over an empty database is worth nothing, so logging speed beats every other consideration. The log screen carries no decoration for this reason.
5. On-device only.
A working example of rule 3: the app distinguishes observed data from estimated data throughout. A cycle length extrapolated during backfill can seed an estimate, but it can never raise a health flag, never narrow a prediction window, and never outvote what you say about your own body.
docs/CYCLE_RULES.md authoritative spec — read before touching the engine
docs/HANDOVER.md state, environment, and the traps that cost hours
spec/ golden fixture: 34 hand-authored cases
android/
core/ plain Kotlin/JVM engine + tests, no Android dependencies
app/ Room, Compose, WorkManager, the widget
Two modules, and the split is the most important structural decision in the project.
core/ is plain Kotlin with zero Android imports. Not "few" — zero, and it is worth keeping that
way. It holds every rule and every calculation: what a cycle is, which phase a day falls in, how wide a
prediction window should be, what counts as a health flag, the backup codec's cryptography. Because
nothing in it touches the framework, its 122 tests run in about ten seconds on any machine, with no
phone, no emulator and no Android SDK. That is why the engine is the best-tested part of the app —
testing it is nearly free, so there was never a reason not to. Add one Android import and that stops
being true, and the cost is not obvious from the diff.
app/ is everything Android: Compose screens, the Room database, WorkManager for the reminder,
RemoteViews for the two widgets, the biometric lock. It decides how things look and when they happen.
It does not decide what anything means.
you log a day LogScreen → LogRepository → Room
│
what the app knows Room ──► CycleProjector ──► CycleEngine ──► Forecast
bleeding days today's cycle the window
→ periods, day, phase, around the
cycles length next period
│
what you see TodayViewModel ──► Today, History, the widgets, the summary
CycleProjector turns a list of bleeding days into periods and cycles. CycleEngine turns those into
today's cycle day, phase and expected length. Forecast turns that into a window and — separately —
into a basis, the receipt explaining which of four sources the number came from.
This is what rule 1 means concretely. Every number on every screen comes out of that chain, and no screen, widget or notification computes one of its own. When the summary and the Today screen agree, it is not because someone kept them in step; it is because they asked the same function.
The same applies to the two ways the app describes a phase, which are deliberately separate types:
Guidance is population-level ("typically…") and SymptomPatterns is your own logs ("you often
log…"). They are never blended, and the type system is what stops it.
cd android
./gradlew :core:test # engine + backup + scoring tests
./gradlew :app:assembleDebug -PminifyDebug # runs R8 on the debug build — see HANDOVER
./gradlew :app:assembleRelease # 2.4 MB, signed if a keystore is presentRequires JDK 17–21 and Android SDK 35. minSdk is 31.
The debug APK is about 25 MB either way; -PminifyDebug shrinks the dex, not the package, and its
real value is exercising the release ProGuard rules early. An earlier note here claimed 7.6 MB — that
was the size of classes.dex, not of the APK.
Release signing reads android/keystore/keystore.properties, which is gitignored along with the
keystore. Without it assembleRelease still succeeds and produces an unsigned APK, which is what CI
builds; unsigned APKs cannot be installed.
Not looking for contributions — it is built around one person's requirements and one spec. The
engine in core/ is dependency-free and reasonably well tested if any of it is useful to you.
This repository contains no real cycle data, deliberately. Every date in the tests and fixtures is synthetic. A backfill seed holding genuine period dates was removed and purged from the git history before this repository was ever published, and verified gone on 2026-08-12 — no SQLite header appears in any object in any ref.
It is enforced rather than remembered. .gitignore blocks databases, .cyc backups, exported
clinical summaries and seed files; CI fails the build if any of them is ever committed, and separately
reads the header of every tracked file, because a database renamed to something innocuous is still a
database. CI also checks the built APK for the INTERNET permission rather than the source, since
a dependency can contribute a permission during manifest merge without anyone writing a line —
ACCESS_NETWORK_STATE is in the APK and in no file here, which is how that was noticed.
Apache License 2.0 (LICENSE, with NOTICE). Chosen on 2026-10-05, replacing the MIT licence
added on 2026-08-15, which had in turn replaced an earlier "all rights reserved" decision.
You can use, change and redistribute this, commercially or not. The engine in core/ is
dependency-free, spec-driven and reasonably well tested, and the comments explain the reasoning
rather than the mechanics, so it may be worth reading even if you only want the cycle logic.
Apache rather than MIT because of the one worry that made this a careful decision. This is one person's medical tool, built to a spec whose rules only make sense together, and a fork that kept the interface but dropped "absent is not zero", or quietly widened a prediction window, could look just as trustworthy while not being so. Apache 2.0 addresses that in two ways MIT does not: it grants no right to the project's name (section 6), and anyone distributing modified files has to mark them as changed (section 4b). So the code is free to reuse, while a modified version cannot pass itself off as this one. If you fork it, please give it a different name.