
- Android
- Kotlin
- Jetpack Compose
- Room
- Offline-first
- Clean Architecture
Subskrypcje, ubezpieczenia i terminy płatności. Offline. Bez konta.
Zrzuty ekranu
Pasek można przewijać w bok. Kliknięcie zrzutu powiększa go — dalej nawigacja strzałkami.Pełny opis
Griff Keeper to prywatna aplikacja na subskrypcje, ubezpieczenia, opłaty cykliczne i terminy — bez konta, bez backendu, bez Firebase i bez synchronizacji w chmurze. Wszystko żyje na telefonie. Jedyny sposób, w jaki dane opuszczają urządzenie, to zaszyfrowany plik kopii zapasowej, który sam tworzysz i sam przenosisz.
CO TRZYMA W JEDNYM MIEJSCU
Odnowienie Netfliksa, ubezpieczenie samochodu wygasające we wrześniu, podatek od nieruchomości z terminem płatności, odnowienie hostingu — i mówi Ci o każdym z nich, zanim zacznie mieć znaczenie.
SUBSKRYPCJE
Lista z logo dostawcy, kategorią, okresem rozliczeniowym i ceną. Miesięczna i roczna suma nigdy nie miesza okresów — roczna subskrypcja liczy się jako rzeczywista kwota roczna, nie jako miesięczna razy dwanaście. Baza 52 popularnych usług polskich i globalnych z domyślnymi adresami zarządzania, plus wpis własny dla wszystkiego innego.
UBEZPIECZENIA I OPŁATY
Ubezpieczenie pojazdu, domu, gruntu, drona, podatek od nieruchomości, podatek rolny — każdy wpis ma osobną datę zapłaty, osobną datę ważności i osobny termin płatności, bo to trzy różne pytania. Lista sortuje się według terminu, który naprawdę ma znaczenie, najbliższy (także zaległy) na górze.
PRZYPOMNIENIA BEZ SIECI
Lokalne powiadomienia zbudowane na WorkManagerze — bez Firebase, bez żadnego dostępu do internetu. Ubezpieczenia przypominają się 30, 7 i 1 dzień przed wygaśnięciem; opłaty i subskrypcje — 7 i 1 dzień przed terminem. Opłacone ubezpieczenie nadal przypomina o końcu ochrony, bo to właśnie warto wiedzieć.
STATYSTYKI, KTÓRE NIE MIESZAJĄ POJĘĆ
Szacowany koszt subskrypcji i rzeczywiście zapłacona opłata to dwie różne liczby — pokazywane osobno, nigdy zsumowane bez zaznaczenia, która część to szacunek.
TWOJE DANE NIGDY NIE OPUSZCZAJĄ TELEFONU SAME
Eksport tworzy zaszyfrowany plik kopii zapasowej, chroniony hasłem, które sam wybierasz — aplikacja go nie zna i nie odzyska. Import najpierw pokazuje podgląd zawartości, zanim cokolwiek zostanie zapisane, a scalanie nigdy nie nadpisuje rekordu nowszą wersją po cichu.
JĘZYK I WALUTA
Pełny polski i angielski interfejs, przełączany w locie. Złoty i euro, niezależnie od języka, z automatycznym przeliczeniem istniejących kwot po kursie, który sam podajesz — bez łączenia się z jakimkolwiek serwisem walutowym.
BEZ REKLAM. BEZ KONTA. BEZ ŚLEDZENIA.
Aplikacja nie ma uprawnienia do internetu. Nie może wysłać niczego, nawet gdyby chciała.
Technologie
Zakres prac: Projekt, architektura, implementacja, wdrożenie
- 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
Realizacja — architektura i technologie
11 min
Realizacja w jednym akapicie
Griff Keeper to natywna aplikacja Android do pilnowania subskrypcji, ubezpieczeń i cyklicznych opłat, zbudowana wokół założenia odwrotnego niż Griff Gym: nie ma tu żadnego backendu, żadnego konta i żadnej sieci. Cała logika i cały model danych powstały według Clean Architecture jako realne moduły Gradle — dokładnie ta sama dyscyplina co w Griff Gymie, zastosowana do produktu, który świadomie nigdy nie połączy się z internetem.
Założenie, które usuwa całą klasę problemów
Aplikacja finansowa, która trzyma dane o ubezpieczeniach i płatnościach, ma oczywisty wybór architektoniczny: backend z kontem, albo nic. Griff Keeper wybiera nic — i to nie jest brak funkcji, tylko świadoma decyzja produktowa. Backend to powierzchnia ataku, koszt utrzymania i miejsce, w którym dane użytkownika mogą wyciec bez jego wiedzy. Aplikacja, która nie ma uprawnienia INTERNET w manifeście, nie może tego zrobić — nawet gdyby ktoś się do niej włamał.
Konsekwencja jest prosta do wypowiedzenia i trudna do cofnięcia później: jedyny sposób, w jaki dane opuszczają telefon, to plik, który użytkownik sam tworzy i sam przenosi. Import/eksport przestaje być funkcją poboczną, a staje się pierwszoplanową częścią produktu.
Architektura — te same granice co w Griff Gymie, inny punkt wyjścia
Clean Architecture jako realne moduły Gradle, z regułą zależności skierowaną do wewnątrz:
:app composition root — Application, MainActivity, bootstrap Hilta
:presentation Compose UI, ViewModele, nawigacja typowana, motyw Material 3
:infrastructure Room, DataStore, WorkManager, kodek kopii zapasowej
:application use case'y
:domain encje, value objecty, porty, reguły biznesoweOdwrócenie zależności w jednym zdaniu: :infrastructure zależy od :domain i implementuje jego porty — SubscriptionRepository, ObligationRepository, ReminderPublisher, BackupCodec, ClockProvider i generatory identyfikatorów — a :domain i :application nie wiedzą, że Room, WorkManager czy javax.crypto w ogóle istnieją. :app jest jedynym modułem, który spina implementacje z portami przez Hilta, i jedynym, który czyta BuildConfig aplikacji.
Przepływ danych idzie jednym kierunkiem w obie strony:
Zdarzenie UI -> ViewModel -> UseCase -> Repository -> Room
Flow z Roomu -> Repository -> UseCase -> StateFlow w ViewModelu -> ComposeEkrany dzielą się na Route (Hilt i zbieranie stanu), Screen (Scaffold, snackbary, dialogi) i Content (bezstanowy układ) — dokładnie ten sam podział co w Griff Gymie — dzięki czemu warstwa Compose daje się testować bez Hilta.
Pieniądze nigdy nie są liczbą zmiennoprzecinkową
Money to klasa wartości nad Long w najmniejszych jednostkach (grosze, centy), z operacjami plus, times i dividedBy zaokrąglanym w górę od połowy, i niezmiennikiem nieujemności. Formatowanie do wyświetlenia — 34,99 zł, 1 299,00 zł, albo 34.99 PLN dla czytelnika anglojęzycznego — dzieje się dopiero na granicy UI, przez CLDR aktywnego locale. Przechowywana kwota nigdy się sama nie zmienia; zmienia ją wyłącznie świadoma, potwierdzona przez użytkownika konwersja waluty.
Normalizacja kosztu też żyje w domenie: monthlyEquivalent dzieli cenę roczną przez 12 (zaokrąglenie w górę od połowy), yearlyEquivalent mnoży cenę miesięczną przez 12, a suma roczna liczy się z rzeczywistych kwot rocznych — nigdy jako sumaMiesięczna × 12, co zniekształcałoby subskrypcje roczne przez zaokrąglenia miesięczne.
Subskrypcje i opłaty to celowo dwa osobne modele
Nie zostały połączone w jedną encję Expense, bo odpowiadają na różne pytania: subskrypcja to cykliczna cena z okresem rozliczeniowym, opłata to jedna kwota ze statusem płatności i aż trzema różnymi datami. Model rozróżnia je świadomie:
| Pole | Znaczenie |
|---|---|
| data zapłaty | jedyna data, do której przypisany jest wydatek — polisa opłacona w grudniu 2026, ważna do grudnia 2027, jest wydatkiem 2026 roku |
| ważność do | napędza przypomnienia o wygaśnięciu, nigdy przypisanie wydatku |
| termin płatności | deadline otwartej, jeszcze nieopłaconej należności |
To nie jest rozróżnienie kosmetyczne. Pomylenie tych trzech dat w statystykach albo w przypomnieniach dawałoby użytkownikowi błędny obraz tego, ile faktycznie wydał w danym roku, i przypominałoby o niewłaściwej rzeczy w niewłaściwym momencie.
Przypomnienia bez jednego bajta wysłanego w sieć
Codzienne zadanie WorkManagera pyta domenę, co dzisiaj oznacza, i wystawia powiadomienia Androida przez NotificationCompat. Nie ma Firebase Cloud Messaging i nie ma żadnego dostępu do sieci. WorkManager sam przeżywa restart telefonu i wymuszone zatrzymanie oraz respektuje Doze, więc aplikacja nie potrzebuje własnego odbiornika BOOT_COMPLETED.
Harmonogram domyślny:
Ubezpieczenia 30, 7 i 1 dzień przed wygaśnięciem
Inne opłaty 7 i 1 dzień przed terminem płatności
Subskrypcje 7 i 1 dzień przed odnowieniemRozliczona opłata przestaje generować przypomnienia o płatności, ale opłacona polisa ubezpieczeniowa nadal generuje przypomnienia o wygaśnięciu — koniec ochrony to właśnie ta rzecz, o której warto wiedzieć, niezależnie od tego, czy rachunek już zamknięty.
Kopia zapasowa jako jedyny most do świata zewnętrznego
Skoro nie ma backendu, przenoszenie danych między urządzeniami przestaje być funkcją poboczną. Kopia zapasowa to dokument logiczny, nie zrzut pliku SQLite:
Room + DataStore
↓
przenośny model kopii (obiekty domenowe)
↓
JSON → GZIP → AES-256-GCM
↓
zaszyfrowany plik .griffbackupFormat nie zależy od języka interfejsu — kategorie, statusy płatności i waluty są zapisywane jako stabilne identyfikatory (MUSIC, VEHICLE_INSURANCE, PLN), nigdy jako słowa, które akurat pokazuje ekran. Kopia zapisana w polskiej wersji aplikacji wczytuje się w angielskiej i odwrotnie.
Szyfrowanie bez klucza wbudowanego w aplikację
PBKDF2WithHmacSHA256, 210 000 iteracji, świeża 128-bitowa sól dla każdej kopii, wynik 256-bitowy. Liczba iteracji jest zapisana w pliku, więc podniesienie jej później nie unieważnia istniejących kopii.
AES-256-GCM, świeży 96-bitowy nonce dla każdej kopii, 128-bitowy tag. Szyfrowanie uwierzytelnione sprawia, że „ten plik został zmieniony" jest wykrywalnym stanem, a nie odszyfrowaniem, które po prostu zwraca wiarygodnie wyglądający szum.
W aplikacji nie ma zaszytego klucza. Stała w APK, w
BuildConfigalbo w zasobie jest odzyskiwalna przez każdego, kto rozpakuje plik instalacyjny, a klucz z Android Keystore jest związany z urządzeniem, które go stworzyło — czyli dokładnie tą właściwością, której kopia zapasowa mieć nie może. Jedyny materiał kluczowy pochodzi z hasła wpisanego przez użytkownika, a aplikacja nie potrafi odzyskać zapomnianego hasła. To świadoma konsekwencja bycia offline.
Cztery bramki, zanim padnie choć jeden zapis
Format — limit rozmiaru, znacznik magiczny, parsowanie nagłówka z kontrolą granic. Wszystko, co się nie zgadza, jest odrzucane jako „to nie jest nasz plik".
Uwierzytelnienie — AES-GCM weryfikuje tag, zanim jakikolwiek fragment jawnego tekstu zostanie zwrócony, więc częściowo odszyfrowany ładunek nigdy nie dotrze do importera. Złe hasło i zmieniony plik są raportowane jako jedna kategoria błędu, bo GCM naprawdę nie potrafi ich rozróżnić.
Wersja schematu — plik z nowszej wersji aplikacji albo ze schematu, którego ta wersja już nie obsługuje, jest odrzucany wprost, zamiast być parsowany na optymistyczne słowo honoru.
Walidacja domenowa — każdy rekord przechodzi te same ograniczenia co rekord wpisany ręcznie. Wszystko albo nic: kopia zapasowa jest jednym dokumentem, a ciche pominięcie rekordów, które nie przeszły walidacji, przywróciłoby coś, czego użytkownik nigdy nie miał.
Zaimportowane rekordy trafiają do bazy w jednej transakcji Roomu, więc częściowo zaaplikowany import nie jest osiągalnym stanem.
Import z trybem Scal i trybem Zastąp
MERGE zostawia lokalne rekordy i dokłada te z kopii — rekord znany po obu stronach jest zostawiany bez zmian, gdy treść jest identyczna, i odświeżany, gdy updatedAt kopii jest nowsze; remis zostawia wiersz lokalny. REPLACE odrzuca lokalne dane portowalne i zostawia dokładnie to, co niosła kopia — wymaga dodatkowego potwierdzenia.
Tożsamością rekordu jest jego własny UUID, nadany raz przy tworzeniu i nigdy nie przepisywany, więc zaimportowanie tego samego pliku dwa razy jest operacją bez efektu, a nie źródłem duplikatów. Rekordy, które tylko wyglądają podobnie (ta sama nazwa, inny identyfikator), są liczone i zgłaszane w podglądzie, nigdy scalane automatycznie — dowodem jest wyłącznie identyfikator.
Brak jednego uprawnienia jako świadoma cecha produktu
Cała lista uprawnień aplikacji to dwie pozycje: powiadomienia lokalne i odczyt stanu sieci (wyłącznie po to, by ostrzec, że udostępniona kopia może utknąć w skrzynce nadawczej klienta pocztowego). Nie ma uprawnienia `INTERNET`. Nie ma żadnego uprawnienia do pamięci masowej — import i eksport idą przez systemowy wybór plików (Storage Access Framework), więc aplikacja dostaje dostęp wyłącznie do tego jednego pliku, który użytkownik sam wskazał.
Stos technologiczny
| Obszar | Technologie |
|---|---|
| Aplikacja | Kotlin, Jetpack Compose, Material 3, Navigation Compose (trasy typowane), Hilt, Coroutines, Flow/StateFlow, WorkManager, DataStore Preferences, AppCompat (języki per-aplikacja) |
| Dane na urządzeniu | Room, SQLite, javax.crypto (AES-256-GCM, PBKDF2), Storage Access Framework |
| Testy | JUnit, Espresso, Compose UI Test, instrumentowane testy migracji Roomu |
| Wdrożenie | Google Play |
Migracje Roomu są wyłącznie addytywne — fallbackToDestructiveMigration() nie jest używane nigdzie w projekcie, a każda zmiana wersji bazy ma swój test migracji uruchamiany na urządzeniu.
Co z tej realizacji wynika
Griff Gym i Griff Keeper to dwa produkty zbudowane w tym samym stylu architektonicznym, ale na przeciwnych biegunach jednej decyzji: mieć backend, czy go nie mieć. Ta sama dyscyplina modułów Gradle, ten sam podział Route/Screen/Content, ta sama zasada trzymania domeny z dala od frameworka — zastosowane raz do aplikacji, która synchronizuje się z serwerem, i raz do aplikacji, która świadomie nigdy się z niczym nie połączy.
Rzecz, którą bym powtórzył: brak backendu to też architektura, nie jej brak. Decyzja, żeby jedynym mostem do świata zewnętrznego był zaszyfrowany plik, który użytkownik sam tworzy i sam przenosi, wymagała tyle samo namysłu co zaprojektowanie API — tylko że tutaj namysł poszedł w kryptografię i w cztery bramki walidacji zamiast w kontrakt HTTP.
