
- Android
- Kotlin
- Jetpack Compose
- Room
- Offline-first
- Clean Architecture
Subscriptions, insurance and payment deadlines. Offline. No account.
Screenshots
Drag the strip sideways to browse. Click a screenshot to enlarge it, then use the arrow keys.Full description
Griff Keeper is a privacy-first app for subscriptions, insurance policies, recurring payments and deadlines — no account, no backend, no Firebase and no cloud sync. Everything lives on your phone. The only way data leaves the device is an encrypted backup file you create and move yourself.
WHAT IT KEEPS IN ONE PLACE
A Netflix renewal, a car insurance policy expiring in September, a property tax deadline, a hosting renewal — and tells you before each one matters.
SUBSCRIPTIONS
A list with the provider's brand glyph, category, billing period and price. Monthly and yearly totals never mix periods — a yearly subscription counts as its real yearly amount, never as monthly times twelve. An offline catalog of 52 popular services with default management links, plus a custom entry for anything else.
INSURANCE & OBLIGATIONS
Vehicle, home, land and drone insurance, property tax, land tax — each record keeps its payment date, its expiry date and its due date apart, because they answer three different questions. The list sorts by whichever deadline actually matters, soonest — including overdue — first.
REMINDERS WITHOUT A NETWORK CONNECTION
Local notifications built on WorkManager — no Firebase, no network access of any kind. Insurance reminds you 30, 7 and 1 day before expiry; obligations and subscriptions, 7 and 1 day before the deadline. A paid policy still reminds you when cover is about to run out, because that is the thing worth knowing.
STATISTICS THAT DON'T BLUR THE MEANING
An estimated subscription cost and a payment you actually made are two different kinds of number — shown side by side, never summed without saying which part is an estimate.
YOUR DATA NEVER LEAVES ON ITS OWN
Export produces an encrypted backup, protected by a password only you choose — the app never sees it and cannot recover it. Import previews what a backup contains before anything is written, and merging never silently overwrites a record with an older one.
LANGUAGE AND CURRENCY
Full Polish and English interface, switchable at runtime. PLN and EUR, independent of the interface language, with existing amounts converted using a rate you supply — no currency service contacted, ever.
NO ADS. NO ACCOUNT. NO TRACKING.
The app has no internet permission. It could not send anything even if it wanted to.
Technology
Scope of work: Product design, architecture, implementation, deployment
- Kotlin
- Jetpack Compose
- Material 3
- Navigation Compose
- Hilt
- Coroutines
- Flow / StateFlow
- WorkManager
- DataStore Preferences
- AppCompat (per-app locales)
- Gradle Kotlin DSL
- KSP
- Room
- SQLite
- javax.crypto (AES-256-GCM, PBKDF2)
- Android Storage Access Framework
- JUnit
- Espresso
- Compose UI Test
- Instrumented Room migration tests
- Google Play
Case study — architecture and technology
11 min
The project in one paragraph
Griff Keeper is a native Android app for tracking subscriptions, insurance and recurring obligations, built on the opposite assumption from Griff Gym: there is no backend, no account and no network here at all. Every bit of logic and every piece of the data model follows Clean Architecture as real Gradle modules — the same discipline as Griff Gym, applied to a product that deliberately never talks to the internet.
The assumption that removes a whole class of problems
A financial app holding insurance and payment data has an obvious architectural fork: a backend with an account, or nothing. Griff Keeper chooses nothing — and that is not a missing feature, it is a deliberate product decision. A backend is attack surface, upkeep cost, and a place where a user's data can leak without them knowing. An app with no INTERNET permission in its manifest cannot do that, even if someone broke into it.
The consequence is easy to state and hard to walk back later: the only way data leaves the phone is a file the user creates and moves themselves. Import/export stops being a side feature and becomes a first-class part of the product.
Architecture — the same boundaries as Griff Gym, a different starting point
Clean Architecture as real Gradle modules, dependencies pointing inward:
:app composition root — Application, MainActivity, Hilt bootstrap
:presentation Compose UI, ViewModels, type-safe navigation, Material 3 theme
:infrastructure Room, DataStore, WorkManager, backup codec
:application use cases
:domain entities, value objects, ports, business rulesDependency inversion in one sentence: :infrastructure depends on :domain and implements its ports — SubscriptionRepository, ObligationRepository, ReminderPublisher, BackupCodec, ClockProvider and the id generators — and :domain/:application do not know Room, WorkManager or javax.crypto exist. :app is the only module wiring implementations to ports through Hilt, and the only one reading the app's BuildConfig.
Data flows one way, both directions:
UI event -> ViewModel -> UseCase -> Repository -> Room
Room Flow -> Repository -> UseCase -> ViewModel StateFlow -> ComposeScreens split into Route (Hilt and state collection), Screen (Scaffold, snackbars, dialogs) and Content (stateless layout) — the exact same split as Griff Gym — which keeps the Compose layer testable without Hilt.
Money is never a floating point number
Money is a value class over Long minor units (grosze, cents), with plus, times and a half-up-rounded dividedBy, plus a non-negative invariant. Display formatting — 34.99 PLN, 1,299.00 PLN, or €34.99 — happens only at the UI boundary, through the active locale's CLDR data. The stored amount never changes on its own; only a deliberate, user-confirmed currency conversion rewrites it.
Cost normalization also lives in the domain: monthlyEquivalent divides a yearly price by 12 (half-up), yearlyEquivalent multiplies a monthly price by 12, and the yearly total is summed from real yearly amounts — never as monthlyTotal × 12, which would distort yearly subscriptions through monthly rounding.
Subscriptions and obligations are deliberately two separate models
They were not merged into one Expense entity because they answer different questions: a subscription is a recurring price with a billing period, an obligation is a single amount with a payment state and as many as three distinct dates. The model keeps them apart on purpose:
| Field | Meaning |
|---|---|
| payment date | the only date an expense is booked to — a policy paid in December 2026, valid until December 2027, is a 2026 expense |
| valid until | drives expiry reminders, never expense attribution |
| due date | the deadline of an open, unpaid charge |
This is not a cosmetic distinction. Conflating those three dates in statistics or reminders would give the user a wrong picture of what they actually spent in a given year, and would remind them about the wrong thing at the wrong time.
Reminders without a single byte sent over the network
A daily WorkManager job asks the domain what today implies and posts Android notifications through NotificationCompat. There is no Firebase Cloud Messaging and no network access of any kind. WorkManager already survives a reboot and a force stop and respects Doze, so the app needs no BOOT_COMPLETED receiver of its own.
Default schedule:
Insurance 30, 7 and 1 day before expiry
Other obligations 7 and 1 day before the due date
Subscriptions 7 and 1 day before renewalA settled charge stops producing payment reminders, but a paid insurance policy still produces expiry reminders — the cover running out is the thing worth knowing about, regardless of whether the bill is already closed.
Backup as the only bridge to the outside world
With no backend, moving data between devices stops being a side feature. A backup is a logical document, not a copy of the SQLite file:
Room + DataStore
↓
portable backup model (domain objects)
↓
JSON → GZIP → AES-256-GCM
↓
encrypted .griffbackup fileThe format does not depend on the interface language — categories, payment states and currencies are written as stable identifiers (MUSIC, VEHICLE_INSURANCE, PLN), never as the words a screen happens to show. A backup written by the Polish app imports into the English one and back.
Encryption with no key baked into the app
PBKDF2WithHmacSHA256, 210,000 iterations, a fresh 128-bit salt per backup, 256-bit output. The iteration count is written into the file, so raising it later does not orphan existing backups.
AES-256-GCM, a fresh 96-bit nonce per backup, 128-bit tag. Authenticated encryption makes "this file was edited" a detectable condition rather than a decryption that returns plausible-looking noise.
There is no key baked into the app. A constant in the APK, in
BuildConfigor in an asset is recoverable by anyone who unzips the download, and an Android Keystore key is bound to the device that created it — exactly the property a backup must not have. The only key material comes from the password the user typed, and the app cannot recover a forgotten one. That is a deliberate consequence of being offline.
Four gates before a single row is written
Format — a size ceiling, a magic marker, bounds-checked header parsing. Anything that does not add up is rejected as "not our file".
Authentication — AES-GCM verifies the tag before any plaintext is returned, so a partially decrypted payload never reaches the importer. A wrong password and an altered file are reported as one category, because GCM genuinely cannot tell them apart.
Schema version — a file from a newer build, or from a schema this build no longer reaches, is refused explicitly rather than parsed optimistically.
Domain validation — every record goes through the same limits a typed-in record would. All-or-nothing: a backup is one document, and silently dropping the records that failed would restore something the user never had.
Imported records land inside one Room transaction, so a half-applied import is not a reachable state.
Import with a Merge mode and a Replace mode
MERGE keeps local records and adds what the backup carries — a record known on both sides is left alone when identical, refreshed when the backup's updatedAt is newer, and ties keep the local row. REPLACE discards local portable data and leaves exactly what the backup carried — it requires an extra confirmation.
Identity is the record's own UUID, minted once on creation and never rewritten, so importing the same file twice is a no-op rather than a source of duplicates. Records that merely look alike (same name, different id) are counted and reported in the preview, never auto-merged — only the id is evidence.
Missing one permission as a deliberate product trait
The app's entire permission list has two entries: local notifications, and a read-only network state check that exists for exactly one thing — warning that a shared backup may sit in an outbox. There is no `INTERNET` permission. There is no storage permission of any kind either — import and export go through the system document picker (Storage Access Framework), so the app gets access only to the one file the user picked.
Technology stack
| Area | Technologies |
|---|---|
| App | Kotlin, Jetpack Compose, Material 3, Navigation Compose (type-safe routes), Hilt, Coroutines, Flow/StateFlow, WorkManager, DataStore Preferences, AppCompat (per-app locales) |
| On-device data | Room, SQLite, javax.crypto (AES-256-GCM, PBKDF2), Storage Access Framework |
| Testing | JUnit, Espresso, Compose UI Test, instrumented Room migration tests |
| Delivery | Google Play |
Room migrations are purely additive — fallbackToDestructiveMigration() is used nowhere in the project, and every schema version bump carries its own instrumented migration test.
What this project actually taught me
Griff Gym and Griff Keeper are two products built in the same architectural style, sitting at opposite poles of one decision: have a backend, or don't. The same Gradle module discipline, the same Route/Screen/Content split, the same rule of keeping the domain away from the framework — applied once to an app that syncs with a server, and once to an app that deliberately never talks to anything.
The thing I would repeat: no backend is still an architecture, not the absence of one. Deciding that the only bridge to the outside world would be an encrypted file the user creates and moves themselves took exactly as much thought as designing an API — it just went into cryptography and four validation gates instead of an HTTP contract.
