commit 2985b3c76ef100b6e66213ecccaa994c245d9942 Author: Christoph K. Date: Sun Apr 5 20:15:47 2026 +0200 Initial commit Co-Authored-By: Claude Sonnet 4.6 diff --git a/README.md b/README.md new file mode 100644 index 0000000..ce4dccf --- /dev/null +++ b/README.md @@ -0,0 +1,632 @@ +Pamietnik ist eine Lebens/Reisejournal (Android +Webapp + Go Server) — Single Source of Truth + +1. Zielbild +Eine Android-App oder Webui erstellt ein Reisejournal, loggt Standortdaten im Background, cached offline, und lädt bevorzugt per HTTP zu einem Go-Server hoch; zusätzlich ist ein Datei-Export aus der App möglich. + +Der Server stellt zusätzlich eine Website bereit (Login erforderlich), auf der pro Tag die gespeicherten Punkte angezeigt werden. + +Das Backend soll basierend auf Logdaten Vorschläge für bestimmte Standorte machen (z. B. wenn man sich länger an einem Ort aufhält). Die Standortinformationen (Reverse-Geocoding/Place-Info) sollen über eine kostenlose API bezogen werden. + +In der App soll es möglich sein, manuell Punkte hinzuzufügen (ohne GPS). + +2. Festlegungen (Decisions) +DEC-CLIENT-01: Android (Kotlin), UI mit Jetpack Compose. + +DEC-CLIENT-LANG-01: Android wird in Kotlin implementiert (Android „Kotlin-first"). + +DEC-LOC-01: Standortlogging im Background ist erforderlich. + +DEC-API-01: Server-Schnittstelle ist HTTP/REST (MQTT verworfen). + +DEC-DOC-01: Alle wesentlichen Inhalte werden in Markdown gepflegt; Diagramme in Mermaid. + +DEC-DB-01: Android lokal/offline: SQLite (über Room); Backend: PostgreSQL. + +DEC-WEB-01: Server bietet Website mit Login; Anzeige der Trackpoints pro Tag. + +DEC-AUTH-01: Für die Website wird Session-basierte Auth (Session Cookie) bevorzugt; JWT ist optional für spätere Erweiterungen (z. B. API-first/SSO). + +DEC-MAP-01: Für Kartenanzeige wird OpenStreetMap-Datenbasis genutzt, mit MapLibre als SDK/Renderer (Android) bzw. Web-Kartenbibliothek nach Wahl; Tile-Quelle wird konfigurierbar gehalten. + +DEC-GEO-01: Für Reverse-Geocoding (kostenlos) wird initial Nominatim (OSM) eingeplant, aber nur mit striktem Caching/Rate-Limiting und „Provider austauschbar"-Design. + +DEC-OPENAPI-01: Die Backend-HTTP-Schnittstelle wird als OpenAPI-Dokument gepflegt (OpenAPI 3.1, YAML oder JSON). + +3. Produktanforderungen (PRD Requirements) +3.1 Background-Logging & Permissions +REQ-LOC-01: Die App MUSS Standort im Vordergrund und im Hintergrund erfassen können. + +REQ-LOC-02: Manifest & Runtime: COARSE (und optional FINE) MUSS deklariert und zur Laufzeit angefragt werden; die App MUSS mit „approximate" funktionieren. + +REQ-LOC-03: Background-Location: Für Hintergrundzugriff MUSS Background-Location berücksichtigt und korrekt angefordert werden (wenn Background-Logging aktiviert ist). + +REQ-LOC-04: Falls Tracking als Foreground Service umgesetzt wird, MUSS beim Service der Location-Typ gesetzt werden (foregroundServiceType="location"). + +3.2 Offline-First, Upload bevorzugt +REQ-SYNC-01: Trackpoints MÜSSEN zuerst lokal persistiert werden (keine rein RAM-basierte Queue). + +REQ-SYNC-02: Upload ist bevorzugter Weg; bei Server-Nichterreichbarkeit MUSS gecached werden und später automatisch nachgesendet werden (Retry). + +REQ-SYNC-03: Hintergrund-Upload SOLL über WorkManager erfolgen, mit Constraints „Network connected" und Retry/Backoff. + +REQ-SYNC-04: Retries dürfen keine Duplikate erzeugen; es MUSS eine Idempotenz-Strategie geben (event_id). + +3.3 Manuelle Punkte +REQ-MAN-01: Die App MUSS es erlauben, manuell einen Punkt hinzuzufügen (mindestens: lat, lon, timestamp; optional: Name/Notiz). + +REQ-MAN-02: Manuelle Punkte MÜSSEN lokal in SQLite/Room gespeichert werden, genauso wie GPS-Punkte. + +REQ-MAN-03: Manuelle Punkte MÜSSEN in den Upload-Flow (Queue) integriert sein und zum Server hochgeladen werden (inkl. event_id). + +REQ-MAN-04: Manuelle Punkte MÜSSEN validiert werden (z. B. Pflichtfelder, Wertebereich) und bei Fehlern im UI eine verständliche Fehlermeldung anzeigen; Validierung kann „as the user types" erfolgen. + +REQ-MAN-05: Der Nutzer SOLL optional „aktuellen Zeitpunkt" und/oder „aktuelle Position" als Vorschlag übernehmen können, aber die Eingabe bleibt manuell editierbar. + +3.4 Export (sekundär) +REQ-EXP-01: Nutzer MUSS Logs/Trips in eine Datei exportieren können. + +REQ-EXP-02: Export MUSS über Storage Access Framework erfolgen (z. B. ACTION_CREATE_DOCUMENT; Nutzer wählt Speicherort, App schreibt in URI). + +3.5 Server Website +REQ-WEB-01: Der Server MUSS eine Website bereitstellen, die nur nach Login zugänglich ist (Auth erforderlich). + +REQ-WEB-02: Nach Login MUSS die Website pro Tag die gespeicherten Punkte anzeigen können (z. B. Tagesliste + Detailansicht eines Tages). + +REQ-WEB-03: Die Website MUSS die Daten nutzerspezifisch anzeigen (ein Nutzer sieht nur seine eigenen Daten / Devices/Trips innerhalb seiner Berechtigung). + +REQ-WEB-04: Die Website SOLL eine Kartenansicht bieten (zusätzlich zur Liste), um Punkte/Stops pro Tag visuell darzustellen. + +3.6 Security / Auth +REQ-AUTH-01: Passwörter DÜRFEN nicht im Klartext gespeichert werden; Password Hashing MUSS sicher erfolgen (Argon2id bevorzugt). + +REQ-AUTH-02: Session-basierte Auth (Cookie) ist erlaubt; Sessions SOLLEN serverseitig verwaltet werden, damit Sessions invalidierbar sind (Logout/Expire). + +REQ-AUTH-03: Auth muss auch für Web-Query-Endpoints gelten (Tagesliste/Tagesdetail sind geschützt). + +3.7 Vorschläge/Stops +REQ-SUG-01: Das Backend MUSS aus Trackpoints „Stops" erkennen können (Aufenthalte an ungefähr gleicher Position über eine Mindestdauer). + +REQ-SUG-02: Das Backend MUSS aus Stops „Vorschläge" ableiten können. + +REQ-SUG-03: Stops/Vorschläge MÜSSEN pro Nutzer/Device/Trip zuordenbar sein und in der Website pro Tag sichtbar sein (Liste + Karte). + +3.8 Kostenlose Standortinfo-API +REQ-GEO-01: Reverse-Geocoding/Place-Info MUSS über einen kostenlosen Dienst erfolgen (initial Nominatim), mit Caching und klaren Limits/Retry-Strategie. + +REQ-GEO-02: Geocoding-Provider MUSS austauschbar sein (z. B. via Server-Config/Proxy), ohne App-Update. + +REQ-GEO-03: Bulk/periodische Reverse-Geocoding Requests SOLLEN vermieden werden; Geocoding wird bevorzugt ereignisbasiert (z. B. pro erkanntem Stop) und gecached. + +3.9 Karten-Dienst +REQ-MAP-01: Kartenanzeige SOLL auf OpenStreetMap-Daten basieren; Rendering/SDK via MapLibre (Android) oder äquivalente Open-Source Web-Kartenkomponente. + +REQ-MAP-02: Tile-Quelle MUSS konfigurierbar sein (später Self-hosting oder Provider-Wechsel möglich). + +3.10 OpenAPI +REQ-OPENAPI-01: Die Backend-HTTP-Schnittstelle MUSS im OpenAPI-Format dokumentiert werden (OAS 3.1). + +REQ-OPENAPI-02: Das OpenAPI-Dokument MUSS als YAML oder JSON im Repository versioniert werden (Entry-Point: openapi.yaml oder openapi.json). + +REQ-OPENAPI-03: Das OpenAPI-Dokument MUSS alle implementierten Endpoints, Schemas (Request/Response), Fehlerformate und Security-Schemes enthalten. + +REQ-OPENAPI-04: Cookie-basierte Auth für Web-Endpoints SOLL im OpenAPI über type: apiKey, in: cookie beschrieben werden (CookieAuth). + +3.11 Google Play / Transparenz +REQ-PRIV-01: Bei Standortzugriff im Hintergrund MUSS eine klare In-App-Erklärung/Disclosure vorhanden sein; Background-Location ist zu begründen und sauber zu deklarieren. + +3.12 Dokumentationsstil +REQ-DOC-STYLE-01: Alle wesentlichen Anforderungen, Architekturentscheidungen, API-Contracts, Testanforderungen und Tasks MÜSSEN in diesem Markdown gepflegt werden. + +REQ-DOC-STYLE-02: Im Chat wird nur minimal kommentiert; Details stehen im Markdown. + +REQ-DOC-STYLE-03: Änderungen erfolgen als fortlaufende Updates dieses Dokuments. + +4. Architektur-Dokumentation (arc42-orientiert) +Die Architekturdokumentation MUSS Kontextsicht, Bausteinsicht, Laufzeitsicht und Verteilungssicht enthalten. +Alle Diagramme MÜSSEN Mermaid sein. +Architektur wird zuerst erstellt und abgestimmt, bevor Implementierung beginnt. + +4.1 Kontextsicht +Fachlicher Kontext (Kommunikationspartner) +Benutzer mobil (startet/stopt Trips, sieht Status, exportiert, fügt manuell Punkte hinzu). + +Benutzer web (loggt sich ein, sieht pro Tag Punkte/Stops/Vorschläge). + +Android OS / Location Services (liefert Standortupdates). + +Webserver (nimmt Trackpoints entgegen, berechnet Stops, stellt Website bereit). + +Geocoding-Dienst (liefert Adress-/Place-Infos). + +Dateisystem via SAF (Export-Ziel). + +Technischer Kontext (Schnittstellen) +Android → Server: HTTPS/REST (JSON oder Protobuf, Entscheidung offen). + +Browser → Server: HTTPS (Web UI, Login, Views). + +Server → Geocoding: HTTPS (Reverse-Geocoding; Provider austauschbar, gecached). + +Android → SAF: ACTION_CREATE_DOCUMENT und Schreiben in content:// URI. + +text +flowchart LR + UserM[User Mobile] -->|Start/Stop Trip, Export, Manual point| App[Android App] + OS[Android Location Services] -->|Location updates| App + App -->|HTTPS REST: trackpoints| API[Go REST API] + API --> DB[(PostgreSQL)] + API -->|HTTPS reverse-geocode cached| GEO[Geocoding Provider] + UserW[User Web] -->|HTTPS| Web[Go Web UI] + Web --> API + App -->|SAF create document| SAF[Android Storage Access Framework] + SAF --> File[(Exported file)] +4.2 Bausteinsicht +Bausteine (initial): + +Android App: UI (Compose), Domain/Use-Cases, Background Location Logging, Manuelle Punkt-Eingabe, Lokale DB + Upload-Queue, Upload Worker, Export, Map View (optional). + +Go Server: REST Router/Handler, Validation, Idempotenz/Dedupe, Persistenz, Observability. + +Go Web UI: Login, Tagesübersicht, Tagesdetail (Punktliste + Karte), Vorschläge. + +Suggestion Engine: Stop Detection + Reverse-Geocoding + Vorschlagslogik. + +Geocoding Adapter: Provider/Proxy + Cache/RateLimit. + +text +flowchart TB + subgraph Mobile[Android App] + UI[Compose UI] + Domain[Domain / Use-Cases] + Loc[Background Location Logging] + Manual[Manual Point Entry + Validation] + Store[(SQLite/Room: Local DB + Upload Queue)] + Worker[Upload Worker] + Export[Export SAF] + UI --> Domain + Domain --> Loc + Domain --> Manual + Loc --> Store + Manual --> Store + Store --> Worker + Domain --> Export + end + + subgraph Server[Go Server] + API[HTTP REST API] + Val[Validation] + Idem[Idempotency/Dedupe] + Auth[Auth users sessions] + Web[Web UI login day view map] + Suggest[Stop Detection + Suggestions] + Geo[Geocoding Adapter + Cache] + SDB[(PostgreSQL)] + API --> Val --> Idem --> SDB + API --> Suggest --> SDB + Suggest --> Geo + Geo -->|HTTPS| Provider[Geocoding Provider] + Web --> Auth + Web --> API + Auth --> SDB + end + + Worker -->|HTTPS Batch Upload| API +4.3 Laufzeitsicht (Szenarien) +Szenarien: + +R1: Trip starten → Background-Logging → Trackpoints in lokale DB. + +R2: Offline → Queue wächst → Online → Batch Upload → Ack → Queue markiert als sent. + +R3: Retry/Duplicate → gleiche event_id erneut → Server erkennt Duplicate → kein doppelter Insert. + +R4: Export → Nutzer wählt Datei → App schreibt Export. + +R5: Web Login → Tagesübersicht → Tagesdetail (Punktliste + Karte). + +R6: Stop erkannt → Reverse-Geocoding (cached) → Vorschlag wird gespeichert → Web zeigt Vorschlag. + +R7: Nutzer fügt Punkt manuell hinzu → Validierung → SQLite/Room persistiert → Queue pending → später Upload. + +text +sequenceDiagram + participant U as User + participant A as Android App + participant L as Location Services + participant D as Local DB Queue + + U->>A: Start Trip + A->>L: Request location updates + L-->>A: Location update trackpoint + A->>D: Persist trackpoint pending +text +sequenceDiagram + participant A as Android App + participant W as WorkManager Worker + participant D as Local DB Queue + participant S as Go REST API + + W->>D: Fetch pending batch + W->>S: POST /v1/trackpoints:batch + alt Server unreachable timeout + S--xW: Network error + W->>D: Keep pending; schedule retry + else Success + S-->>W: Ack accepted_ids rejected[] + W->>D: Mark accepted as sent; keep rejected with reason + end +text +sequenceDiagram + participant B as Browser + participant W as Go Web UI + participant A as Auth sessions + participant API as Go REST API + participant DB as PostgreSQL + + B->>W: GET /login + B->>W: POST /login username + password + W->>A: Verify credentials + create session + A->>DB: Load user + verify hash + A-->>W: Session created + W-->>B: Set-Cookie session + redirect + + B->>W: GET /days + W->>API: GET /v1/days auth + API->>DB: Query days group filter by date + API-->>W: days[] + W-->>B: Render days view + + B->>W: GET /days/yyyy-mm-dd + W->>API: GET /v1/trackpoints?date=YYYY-MM-DD + API->>DB: Query trackpoints for date + API-->>W: trackpoints[] + W-->>B: Render day detail +text +sequenceDiagram + participant W as WorkManager Worker + participant S as Go REST API + participant DB as PostgreSQL + participant SE as Suggestion Engine + participant G as Geocoding Adapter Cache + participant P as Geocoding Provider + + W->>S: POST /v1/trackpoints:batch + S->>DB: Persist trackpoints + S->>SE: Trigger stop detection async + SE->>DB: Read recent points + SE->>G: Reverse-geocode stop if not cached + alt Cache hit + G-->>SE: Place info cached + else Cache miss + G->>P: HTTPS reverse-geocode + P-->>G: Place info + G-->>SE: Place info + end + SE->>DB: Persist stop + suggestion +text +sequenceDiagram + participant U as User + participant A as Android App + participant V as Validation ViewModel + participant D as SQLite Room Queue + participant W as Upload Worker + participant S as Go REST API + + U->>A: Add manual point lat/lon/time/note + A->>V: Update fields as user types + V-->>A: Show validation errors or OK + A->>D: Persist trackpoint source=manual pending + W->>D: Fetch pending batch + W->>S: POST /v1/trackpoints:batch + S-->>W: Ack + W->>D: Mark sent +4.4 Verteilungssicht +text +flowchart LR + Phone[Android Device] -->|Internet HTTPS| Edge[Reverse Proxy LB optional] + Browser[Web Browser] -->|Internet HTTPS| Edge + Edge --> API[Go API Service REST + Web UI] + API --> DB[(PostgreSQL)] + API --> GEO[Geocoding Adapter Proxy + Cache] + GEO -->|HTTPS| Provider[Geocoding Provider] +5. HTTP API (REST) — Vertrag (Draft) +5.1 Ziele +Single-Point ingest und Batch ingest. + +Idempotenz über event_id (mind. unique pro device). + +Klare Acks für Queue-Weiterdrehen. + +Web UI benötigt Tagesaggregation und Tagesdetail. + +Vorschläge/Stops müssen abfragbar sein. + +5.2 Endpoints (Vorschlag) +Ingest: + +POST /v1/trackpoints (single) + +POST /v1/trackpoints:batch (batch) + +GET /healthz + +GET /readyz + +Web/Query: + +GET /v1/days?from=YYYY-MM-DD&to=YYYY-MM-DD + +GET /v1/trackpoints?date=YYYY-MM-DD + +GET /v1/stops?date=YYYY-MM-DD + +GET /v1/suggestions?date=YYYY-MM-DD + +Web UI Routes (serverseitig gerendert): + +GET /login + +POST /login + +POST /logout + +GET /days + +GET /days/{yyyy-mm-dd} + +5.3 Datenmodell (Draft) +Trackpoint: + +event_id (string, client-generated; UUID empfohlen) + +device_id (string) + +trip_id (string) + +timestamp (RFC3339 oder epochMillis, offen) + +lat, lon (float) + +source: "gps" | "manual" (optional; Default: gps) + +note (string, optional; nur bei manuellen Punkten) + +optional: accuracy_m, speed_mps, bearing_deg, altitude_m + +Stop: + +stop_id + +device_id, trip_id + +start_ts, end_ts + +center_lat, center_lon + +duration_s + +place_label (optional, aus reverse-geocode) + +place_details (optional JSON) + +Suggestion: + +suggestion_id + +stop_id + +type (z. B. "highlight", "name_place", "add_note") + +title/text + +created_at + +dismissed_at (optional) + +Batch response: + +server_time + +accepted_ids (string[]) + +rejected (array of {event_id, code, message}) + +Day summary: + +date (YYYY-MM-DD) + +count (int) + +first_ts, last_ts (optional) + +5.4 Idempotenz-Regel +Unique Key: (device_id, event_id) + +Duplicate: Server antwortet erfolgreich (z. B. 200) und behandelt es als „already processed". + +5.5 Auth (Draft) +Website-Zugriff MUSS Login erfordern (Session Cookie). + +API-Calls für Web UI müssen Auth prüfen (Session). + +Android Upload Auth ist separat zu entscheiden (API-Key vs Bearer/JWT). + +6. Auth: Session Cookie vs JWT +Session Cookie (session-basiert, stateful) +Vorteile: + +Browser sendet Cookies automatisch mit; serverseitige Sessions sind gut kontrollierbar. + +Sofortiger Widerruf möglich (Logout = Session löschen). + +Nachteile: + +Server muss Session-State speichern und über Instanzen verfügbar machen (z. B. Postgres/Redis). + +JWT (token-basiert, stateless) +Vorteile: + +Stateless pro Request; skaliert oft leichter für reine APIs/mehrere Services. + +Gut für API-first/SSO/Verteilung. + +Nachteile: + +Widerruf/Revocation, Rotation und Lebensdauer-Management erhöhen Komplexität. + +Empfehlung für RALPH +Website: Session Cookie. + +JWT optional später, falls API-first/SSO nötig wird. + +7. Testing (Requirements) +7.1 Use-Case Coverage +Alle wichtigen Use Cases (R1–R7, plus Duplicate/Retry und Validierungsfehler) MÜSSEN automatisiert getestet werden. + +7.2 Android Unit Tests +REQ-TEST-A01: Lokale JVM-Unit-Tests für Domain/Queue/Serializer/Retry-Entscheidungen. + +REQ-TEST-A02: Unit Tests für Validierungslogik manueller Punkte. + +7.3 Android Integration/Instrumentation (WorkManager) +REQ-TEST-A03: Integrationstests für Worker/Constraints/Delays mit WorkManager-Testunterstützung. + +7.4 Go Unit & HTTP Handler Tests +REQ-TEST-S01: Go Unit Tests via testing (go test ./...). + +REQ-TEST-S02: REST Handler Tests (inkl. /v1/days, /v1/trackpoints?date=…, /v1/stops, /v1/suggestions) mit net/http/httptest. + +7.5 System-/Integrationstests (App ↔ Server) +REQ-TEST-I01: Integrationstest: Pending Queue → Batch Upload → Ack → Mark sent. + +REQ-TEST-I02: Offline→Online-Wechsel: zuerst Fehler, später Erfolg. + +REQ-TEST-I03: Duplicate event_id: gleicher Batch zweimal → keine Duplikate. + +REQ-TEST-I04: Manual point → Queue → Upload → Ack (End-to-End). + +7.6 Web UI Tests +REQ-TEST-W01: Login-Flow muss getestet werden (gültig/ungültig, Session created, Zugriffsschutz). + +REQ-TEST-W02: Tagesansicht muss getestet werden (korrekte Tage, korrekte Punkte/Stops/Vorschläge pro Tag, AuthZ). + +7.7 Geocoding/RateLimit Tests +REQ-TEST-GEO-01: Tests müssen sicherstellen, dass Reverse-Geocoding gecached wird und keine Bulk/periodischen Calls entstehen. + +REQ-TEST-GEO-02: Provider-Wechsel muss testbar sein (Config umstellen → anderer Endpoint, ohne App-Update). + +8. Backlog (Tasks) +8.1 Reihenfolge (wichtig) + T000: Architektur in diesem Dokument finalisieren (Kontext, Bausteine, Laufzeit, Verteilung) und abnehmen, bevor Implementierung startet. + + T000e: Architektur-Review/Abnahme-Checkpoint (Issue/PR), erst danach Implementierungs-Tasks starten. + +8.2 Android (Client) + T001: Compose-Projekt anlegen (Kotlin, minSdk festlegen). + + T001a: Kotlin-Standards festlegen (Coroutines-Usage, KTX/Extensions, Code Style). + + T004: Permissions im Manifest + Runtime-Flow (COARSE/FINE + Background). + + T006: Background-Tracking Architektur (Provider/Manager/Service falls nötig). + + T033: Android Persistenz: Room-Setup (Entities/DAOs/Migrations) auf SQLite-Basis. + + T008: Lokale DB + Upload-Queue (pending/sent/failed, retryCount). + + T012: Upload-Worker (Constraints Network connected, Retry/Backoff). + + T018: Export via SAF ACTION_CREATE_DOCUMENT + Schreiben in URI. + + T060: App-Kartenansicht (Track/Stops lokal anzeigen), SDK nach DEC-MAP-01 (optional). + + T080: UI Screen/Flow „Manuellen Punkt hinzufügen" (lat/lon, timestamp, note optional). + + T081: Eingabevalidierung in Compose (as the user types) + Fehlermeldungen. + + T082: Persistenz: Manual-Point als Trackpoint in Room speichern, inkl. source=manual. + + T083: Upload: Manual-Points in Batch/Single Upload einbeziehen (idempotent über event_id). + + T084: Tests: Unit Tests für Validierung + Mapping; Integrationstest: Manual point → Queue → Upload → Ack. + +8.3 Server (Go, HTTP + Web UI) +API / Persistenz: + + T024: REST API finalisieren (Single + Batch, Fehlerformat, Limits). + + T027: Persistenz: Postgres Schema + Migrationen + Indizes + Retention. + + T028: Server-Idempotenz (unique event_id pro device). + + T029: Observability (Logs/Metrics), Health/Ready. + + T030: E2E lokal: docker compose (API + Postgres) + Minimal-Client zum Senden. + +Auth / Website: + + T050: User-/Auth-Konzept festlegen (User, Devices, Ownership, Session-Lifetime). + + T051: Passwort-Hashing implementieren (Argon2id bevorzugt). + + T052: Session-Management implementieren (Session-Store in Postgres, Cookie setzen, Logout/Invalidate). + + T053: Web UI: Login-Seite (GET/POST), Zugriffsschutz (Middleware). + + T054: Web UI: Tagesübersicht (GET /days) + Tagesdetail (GET /days/{date}) inkl. Karte. + + T055: API: GET /v1/days und GET /v1/trackpoints?date=YYYY-MM-DD (Auth required). + + T061: Stop Detection implementieren (Parameter: minDuration, radiusMeters; konfigurierbar). + + T062: Suggestions implementieren (aus Stops ableiten; Storage + API). + + T063: Geocoding Adapter + Cache + RateLimit + Provider-Wechsel implementieren. + + T064: Web UI: Stops/Vorschläge pro Tag anzeigen (Liste + Karte). + +OpenAPI: + + T070: OpenAPI-Dokument anlegen (openapi.yaml als OAS 3.1) und im Repo versionieren. + + T071: OpenAPI vollständig halten: alle Endpoints/Schemas/Responses/Errors/Security abbilden, inkl. CookieAuth für Web-API. + + T072: OpenAPI in CI validieren (Spec-Validation) und Änderungen nur via PR. + +8.4 Tests + T040: Use-Case-Matrix (Use Case → Testart). + + T041: Android Unit Tests (Queue/Serializer/Retry/Validation). + + T042: Android WorkManager Integrationstests. + + T043: Go Unit Tests (testing). + + T044: Go HTTP Handler Tests (httptest). + + T045: Integrationstests (E2E ingest + Ack + Dedupe). + + T057: Web Login Tests (happy path + invalid creds + session required). + + T058: Web Tagesansicht Tests (korrekte Tage, korrekte Punkte pro Tag, AuthZ). + + T065: Stop/Suggestion Tests (synthetische Trackpoints → erwartete Stops/Vorschläge). + + T066: Geocoding Cache/RateLimit Tests (keine Bulk Calls; Provider switch). + +9. Offene Entscheidungen (Checklist) + timestamp Format: epochMillis vs RFC3339. + + Android Upload Auth: X-API-Key vs Bearer/JWT (Web bleibt Session Cookie). + + Payload: JSON vs Protobuf über HTTP. + + Batch limits (max items, max bytes). + + Server Retention-Policy (wie lange Trackpoints gespeichert werden). + + Stop-Detection Parameter (min Dauer, Radius). + + Karten-Tile-Provider (konkreter Anbieter/Hosting; muss austauschbar bleiben). + + Geocoding Provider (konkret: Nominatim public vs self-hosted Nominatim vs alternative freie Provider); Nutzung muss Policy-konform sein. \ No newline at end of file diff --git a/app/.gitignore b/app/.gitignore new file mode 100644 index 0000000..bf84bd4 --- /dev/null +++ b/app/.gitignore @@ -0,0 +1,16 @@ +*.iml +.gradle/ +.idea/ +/local.properties +/.idea/workspace.xml +/.idea/libraries +.DS_Store +/build/ +/captures/ +.externalNativeBuild/ +.cxx/ +*.keystore +!debug.keystore +local.properties +app/build/ +app/.cxx/ diff --git a/app/CLAUDE.md b/app/CLAUDE.md new file mode 100644 index 0000000..09634fd --- /dev/null +++ b/app/CLAUDE.md @@ -0,0 +1,65 @@ +# CLAUDE.md — RALPH Android Frontend + +## Stack + +Language: Kotlin +UI: Jetpack Compose +DB: Room (SQLite) +Worker: WorkManager +Maps: MapLibre + OpenStreetMap +HTTP: Retrofit oder Ktor Client (TBD) +Auth: TBD (API-Key vs JWT für Upload) + +--- + +## Kern-Features (Android) + +1. Background GPS-Tracking (COARSE/FINE + Background permission) +2. Offline Queue (Room) + WorkManager Upload (Idempotenz via event_id) +3. Manuelle Punkte hinzufügen (lat/lon/timestamp/notiz + Validierung) +4. Kartenansicht (MapLibre, Tile-Quelle konfigurierbar) +5. Datei-Export (SAF ACTION_CREATE_DOCUMENT) + +--- + +## Architektur-Prinzipien + +- Offline-First: Jeder Punkt wird zuerst lokal in Room gespeichert +- Upload via WorkManager mit Constraint: Network connected + Retry/Backoff +- Idempotenz: Jeder Punkt erhält eine client-seitige event_id (UUID) +- source-Feld: "gps" | "manual" unterscheidet Punkt-Typ +- Validierung manueller Punkte: as the user types im ViewModel + + +--- + +## Offene Entscheidungen (TBD) + +- HTTP Client: Retrofit vs Ktor +- timestamp Format: epochMillis vs RFC3339 +- Android Upload Auth: X-API-Key vs JWT +- minSdk festlegen + +--- + +## Nächste Tasks (Reihenfolge) + +- [ ] T001 Compose-Projekt anlegen (Kotlin, minSdk festlegen) +- [ ] T001a Kotlin-Standards festlegen (Coroutines, KTX, Code Style) +- [ ] T004 Permissions Manifest + Runtime-Flow (COARSE/FINE + Background) +- [ ] T006 Background-Tracking Architektur (Foreground Service) +- [ ] T033 Room-Setup (Entities/DAOs/Migrations) +- [ ] T008 Upload-Queue (pending/sent/failed, retryCount) +- [ ] T012 Upload-Worker (WorkManager, Retry/Backoff) +- [ ] T080 UI: Manuellen Punkt hinzufügen +- [ ] T081 Eingabevalidierung Compose (as the user types) +- [ ] T018 Export via SAF + +--- + +## Antwort-Stil + +- Kotlin-Code immer mit Coroutines/Flow +- Compose-Code: State Hoisting, ViewModel pattern +- Kein direkter DB/Network-Zugriff in Composables +- Tests: JVM Unit Tests für Domain/Queue; Instrumentation für Room/Worker diff --git a/app/app/build.gradle.kts b/app/app/build.gradle.kts new file mode 100644 index 0000000..2d32a5c --- /dev/null +++ b/app/app/build.gradle.kts @@ -0,0 +1,103 @@ +plugins { + alias(libs.plugins.android.application) + alias(libs.plugins.kotlin.android) + alias(libs.plugins.kotlin.compose) + alias(libs.plugins.ksp) + alias(libs.plugins.hilt) +} + +android { + namespace = "de.jacek.reisejournal" + compileSdk = 35 + + defaultConfig { + applicationId = "de.jacek.reisejournal" + minSdk = 26 + targetSdk = 35 + versionCode = 1 + versionName = "1.0" + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + } + + buildTypes { + release { + isMinifyEnabled = true + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) + } + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + kotlinOptions { + jvmTarget = "17" + } + + buildFeatures { + compose = true + } +} + +dependencies { + // Compose + val composeBom = platform(libs.compose.bom) + implementation(composeBom) + androidTestImplementation(composeBom) + + implementation(libs.compose.ui) + implementation(libs.compose.ui.graphics) + implementation(libs.compose.ui.tooling.preview) + implementation(libs.compose.material3) + implementation(libs.compose.activity) + implementation(libs.navigation.compose) + implementation(libs.lifecycle.viewmodel.compose) + debugImplementation(libs.compose.ui.tooling) + debugImplementation(libs.compose.ui.test.manifest) + + // Hilt + implementation(libs.hilt.android) + ksp(libs.hilt.compiler) + implementation(libs.hilt.navigation.compose) + + // Room + implementation(libs.room.runtime) + implementation(libs.room.ktx) + ksp(libs.room.compiler) + + // WorkManager + implementation(libs.workmanager) + implementation(libs.hilt.work) + ksp(libs.hilt.work.compiler) + + // Retrofit + Moshi + implementation(libs.retrofit) + implementation(libs.retrofit.moshi) + implementation(libs.moshi.kotlin) + ksp(libs.moshi.codegen) + implementation(libs.okhttp.logging) + + // Location + implementation(libs.play.services.location) + + // DataStore + implementation(libs.datastore.preferences) + + // MapLibre + implementation(libs.maplibre) + + // Coroutines + implementation(libs.coroutines.core) + implementation(libs.coroutines.android) + + // Tests + testImplementation(libs.junit) + androidTestImplementation(libs.junit.ext) + androidTestImplementation(libs.espresso.core) + androidTestImplementation(libs.compose.ui.test.junit4) +} diff --git a/app/app/proguard-rules.pro b/app/app/proguard-rules.pro new file mode 100644 index 0000000..52cb466 --- /dev/null +++ b/app/app/proguard-rules.pro @@ -0,0 +1,25 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. + +# Moshi +-keepclassmembers class * { + @com.squareup.moshi.FromJson *; + @com.squareup.moshi.ToJson *; +} + +# Retrofit +-keepattributes Signature, InnerClasses, EnclosingMethod +-keepattributes RuntimeVisibleAnnotations, RuntimeVisibleParameterAnnotations +-keepclassmembers,allowshrinking,allowobfuscation interface * { + @retrofit2.http.* ; +} +-dontwarn org.codehaus.mojo.animal_sniffer.IgnoreJRERequirement +-dontwarn javax.annotation.** +-dontwarn kotlin.Unit +-dontwarn retrofit2.KotlinExtensions +-dontwarn retrofit2.KotlinExtensions$* + +# OkHttp +-dontwarn okhttp3.** +-dontwarn okio.** diff --git a/app/app/src/androidTest/kotlin/de/jacek/reisejournal/ExampleInstrumentedTest.kt b/app/app/src/androidTest/kotlin/de/jacek/reisejournal/ExampleInstrumentedTest.kt new file mode 100644 index 0000000..fe83988 --- /dev/null +++ b/app/app/src/androidTest/kotlin/de/jacek/reisejournal/ExampleInstrumentedTest.kt @@ -0,0 +1,16 @@ +package de.jacek.reisejournal + +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.ext.junit.runners.AndroidJUnit4 +import org.junit.Test +import org.junit.runner.RunWith +import org.junit.Assert.* + +@RunWith(AndroidJUnit4::class) +class ExampleInstrumentedTest { + @Test + fun useAppContext() { + val appContext = InstrumentationRegistry.getInstrumentation().targetContext + assertEquals("de.jacek.reisejournal", appContext.packageName) + } +} diff --git a/app/app/src/main/AndroidManifest.xml b/app/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..a74e4b2 --- /dev/null +++ b/app/app/src/main/AndroidManifest.xml @@ -0,0 +1,42 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/app/src/main/kotlin/de/jacek/reisejournal/MainActivity.kt b/app/app/src/main/kotlin/de/jacek/reisejournal/MainActivity.kt new file mode 100644 index 0000000..43c5851 --- /dev/null +++ b/app/app/src/main/kotlin/de/jacek/reisejournal/MainActivity.kt @@ -0,0 +1,22 @@ +package de.jacek.reisejournal + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import dagger.hilt.android.AndroidEntryPoint +import de.jacek.reisejournal.ui.navigation.NavGraph +import de.jacek.reisejournal.ui.theme.RalphTheme + +@AndroidEntryPoint +class MainActivity : ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + enableEdgeToEdge() + setContent { + RalphTheme { + NavGraph() + } + } + } +} diff --git a/app/app/src/main/kotlin/de/jacek/reisejournal/RalphApp.kt b/app/app/src/main/kotlin/de/jacek/reisejournal/RalphApp.kt new file mode 100644 index 0000000..c344011 --- /dev/null +++ b/app/app/src/main/kotlin/de/jacek/reisejournal/RalphApp.kt @@ -0,0 +1,19 @@ +package de.jacek.reisejournal + +import android.app.Application +import androidx.hilt.work.HiltWorkerFactory +import androidx.work.Configuration +import dagger.hilt.android.HiltAndroidApp +import javax.inject.Inject + +@HiltAndroidApp +class RalphApp : Application(), Configuration.Provider { + + @Inject + lateinit var workerFactory: HiltWorkerFactory + + override val workManagerConfiguration: Configuration + get() = Configuration.Builder() + .setWorkerFactory(workerFactory) + .build() +} diff --git a/app/app/src/main/kotlin/de/jacek/reisejournal/domain/Trackpoint.kt b/app/app/src/main/kotlin/de/jacek/reisejournal/domain/Trackpoint.kt new file mode 100644 index 0000000..5863aea --- /dev/null +++ b/app/app/src/main/kotlin/de/jacek/reisejournal/domain/Trackpoint.kt @@ -0,0 +1,16 @@ +package de.jacek.reisejournal.domain + +data class Trackpoint( + val eventId: String, // UUID, client-generated + val deviceId: String, + val tripId: String?, + val timestamp: String, // RFC3339, e.g. "2024-01-15T10:30:00Z" + val lat: Double, + val lon: Double, + val source: String, // "gps" | "manual" + val note: String?, + val accuracy: Float?, + val speed: Float?, + val bearing: Float?, + val altitude: Double?, +) diff --git a/app/app/src/main/kotlin/de/jacek/reisejournal/ui/home/HomeScreen.kt b/app/app/src/main/kotlin/de/jacek/reisejournal/ui/home/HomeScreen.kt new file mode 100644 index 0000000..3cd1407 --- /dev/null +++ b/app/app/src/main/kotlin/de/jacek/reisejournal/ui/home/HomeScreen.kt @@ -0,0 +1,52 @@ +package de.jacek.reisejournal.ui.home + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.hilt.navigation.compose.hiltViewModel +import de.jacek.reisejournal.R + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun HomeScreen( + viewModel: HomeViewModel = hiltViewModel() +) { + val uiState by viewModel.uiState.collectAsState() + + Scaffold( + topBar = { + TopAppBar( + title = { Text(stringResource(R.string.app_name)) } + ) + } + ) { paddingValues -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Text( + text = stringResource(R.string.app_name), + style = MaterialTheme.typography.headlineMedium, + ) + Text( + text = "Punkte: ${uiState.trackpointCount}", + style = MaterialTheme.typography.bodyLarge, + ) + } + } +} diff --git a/app/app/src/main/kotlin/de/jacek/reisejournal/ui/home/HomeViewModel.kt b/app/app/src/main/kotlin/de/jacek/reisejournal/ui/home/HomeViewModel.kt new file mode 100644 index 0000000..af9b43c --- /dev/null +++ b/app/app/src/main/kotlin/de/jacek/reisejournal/ui/home/HomeViewModel.kt @@ -0,0 +1,20 @@ +package de.jacek.reisejournal.ui.home + +import androidx.lifecycle.ViewModel +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import javax.inject.Inject + +data class HomeUiState( + val isTracking: Boolean = false, + val trackpointCount: Int = 0, +) + +@HiltViewModel +class HomeViewModel @Inject constructor() : ViewModel() { + + private val _uiState = MutableStateFlow(HomeUiState()) + val uiState: StateFlow = _uiState.asStateFlow() +} diff --git a/app/app/src/main/kotlin/de/jacek/reisejournal/ui/navigation/NavGraph.kt b/app/app/src/main/kotlin/de/jacek/reisejournal/ui/navigation/NavGraph.kt new file mode 100644 index 0000000..beeb41d --- /dev/null +++ b/app/app/src/main/kotlin/de/jacek/reisejournal/ui/navigation/NavGraph.kt @@ -0,0 +1,24 @@ +package de.jacek.reisejournal.ui.navigation + +import androidx.compose.runtime.Composable +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable +import androidx.navigation.compose.rememberNavController +import de.jacek.reisejournal.ui.home.HomeScreen + +const val HOME_ROUTE = "home" + +@Composable +fun NavGraph() { + val navController = rememberNavController() + + NavHost( + navController = navController, + startDestination = HOME_ROUTE, + ) { + composable(HOME_ROUTE) { + HomeScreen() + } + // Future routes: map, add_point, settings, export + } +} diff --git a/app/app/src/main/kotlin/de/jacek/reisejournal/ui/theme/Color.kt b/app/app/src/main/kotlin/de/jacek/reisejournal/ui/theme/Color.kt new file mode 100644 index 0000000..e05ead8 --- /dev/null +++ b/app/app/src/main/kotlin/de/jacek/reisejournal/ui/theme/Color.kt @@ -0,0 +1,20 @@ +package de.jacek.reisejournal.ui.theme + +import androidx.compose.ui.graphics.Color + +val Primary = Color(0xFF2E7D32) // Forest green +val OnPrimary = Color(0xFFFFFFFF) +val PrimaryContainer = Color(0xFFA5D6A7) +val OnPrimaryContainer = Color(0xFF003909) + +val Secondary = Color(0xFF558B2F) +val OnSecondary = Color(0xFFFFFFFF) +val SecondaryContainer = Color(0xFFCCFF90) +val OnSecondaryContainer = Color(0xFF1B5E20) + +val Background = Color(0xFFF8FFF8) +val OnBackground = Color(0xFF1A1C1A) +val Surface = Color(0xFFF8FFF8) +val OnSurface = Color(0xFF1A1C1A) +val SurfaceVariant = Color(0xFFDCE5DC) +val OnSurfaceVariant = Color(0xFF404940) diff --git a/app/app/src/main/kotlin/de/jacek/reisejournal/ui/theme/Theme.kt b/app/app/src/main/kotlin/de/jacek/reisejournal/ui/theme/Theme.kt new file mode 100644 index 0000000..252d621 --- /dev/null +++ b/app/app/src/main/kotlin/de/jacek/reisejournal/ui/theme/Theme.kt @@ -0,0 +1,61 @@ +package de.jacek.reisejournal.ui.theme + +import android.os.Build +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.dynamicDarkColorScheme +import androidx.compose.material3.dynamicLightColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.platform.LocalContext + +private val LightColorScheme = lightColorScheme( + primary = Primary, + onPrimary = OnPrimary, + primaryContainer = PrimaryContainer, + onPrimaryContainer = OnPrimaryContainer, + secondary = Secondary, + onSecondary = OnSecondary, + secondaryContainer = SecondaryContainer, + onSecondaryContainer = OnSecondaryContainer, + background = Background, + onBackground = OnBackground, + surface = Surface, + onSurface = OnSurface, + surfaceVariant = SurfaceVariant, + onSurfaceVariant = OnSurfaceVariant, +) + +private val DarkColorScheme = darkColorScheme( + primary = PrimaryContainer, + onPrimary = OnPrimaryContainer, + primaryContainer = Primary, + onPrimaryContainer = OnPrimary, + secondary = SecondaryContainer, + onSecondary = OnSecondaryContainer, + secondaryContainer = Secondary, + onSecondaryContainer = OnSecondary, +) + +@Composable +fun RalphTheme( + darkTheme: Boolean = isSystemInDarkTheme(), + dynamicColor: Boolean = true, + content: @Composable () -> Unit +) { + val colorScheme = when { + dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> { + val context = LocalContext.current + if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) + } + darkTheme -> DarkColorScheme + else -> LightColorScheme + } + + MaterialTheme( + colorScheme = colorScheme, + typography = Typography, + content = content + ) +} diff --git a/app/app/src/main/kotlin/de/jacek/reisejournal/ui/theme/Type.kt b/app/app/src/main/kotlin/de/jacek/reisejournal/ui/theme/Type.kt new file mode 100644 index 0000000..9a57238 --- /dev/null +++ b/app/app/src/main/kotlin/de/jacek/reisejournal/ui/theme/Type.kt @@ -0,0 +1,31 @@ +package de.jacek.reisejournal.ui.theme + +import androidx.compose.material3.Typography +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.sp + +val Typography = Typography( + bodyLarge = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Normal, + fontSize = 16.sp, + lineHeight = 24.sp, + letterSpacing = 0.5.sp + ), + titleLarge = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Normal, + fontSize = 22.sp, + lineHeight = 28.sp, + letterSpacing = 0.sp + ), + labelSmall = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Medium, + fontSize = 11.sp, + lineHeight = 16.sp, + letterSpacing = 0.5.sp + ) +) diff --git a/app/app/src/main/res/values/strings.xml b/app/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..603e144 --- /dev/null +++ b/app/app/src/main/res/values/strings.xml @@ -0,0 +1,4 @@ + + + Reisejournal + diff --git a/app/app/src/main/res/values/themes.xml b/app/app/src/main/res/values/themes.xml new file mode 100644 index 0000000..9658ad1 --- /dev/null +++ b/app/app/src/main/res/values/themes.xml @@ -0,0 +1,5 @@ + + + +